diff --git a/.changeset/chilled-peas-taste.md b/.changeset/chilled-peas-taste.md new file mode 100644 index 00000000..6af9eba7 --- /dev/null +++ b/.changeset/chilled-peas-taste.md @@ -0,0 +1,5 @@ +--- +'backend': patch +--- + +adding dynamic swap fee to fx pools diff --git a/graphql_schema_generated.ts b/graphql_schema_generated.ts index ac4f7a97..53d3547f 100644 --- a/graphql_schema_generated.ts +++ b/graphql_schema_generated.ts @@ -3322,6 +3322,7 @@ export const schema = gql` poolReloadStakingForAllPools(stakingTypes: [GqlPoolStakingType!]!): String! poolSyncAllCowSnapshots(chains: [GqlChain!]!): [GqlPoolMutationResult!]! poolSyncAllPoolsFromSubgraph: [String!]! + poolSyncFxQuoteTokens(chains: [GqlChain!]!): [GqlPoolMutationResult!]! poolUpdateLifetimeValuesForAllPools: String! poolUpdateLiquidityValuesForAllPools: String! protocolCacheMetrics: String! diff --git a/modules/actions/pool/v2/add-pools.ts b/modules/actions/pool/v2/add-pools.ts index afc38255..02a7c76b 100644 --- a/modules/actions/pool/v2/add-pools.ts +++ b/modules/actions/pool/v2/add-pools.ts @@ -1,4 +1,4 @@ -import { Chain } from '@prisma/client'; +import { Chain, PrismaPool } from '@prisma/client'; import { prisma } from '../../../../prisma/prisma-client'; import { nestedPoolWithSingleLayerNesting } from '../../../../prisma/prisma-types'; import { V2SubgraphClient } from '../../../subgraphs/balancer-subgraph'; @@ -6,6 +6,7 @@ import { BalancerPoolFragment } from '../../../subgraphs/balancer-subgraph/gener import { subgraphToPrismaCreate } from '../../../pool/subgraph-mapper'; import { upsertBptBalancesV2 } from '../../user/upsert-bpt-balances-v2'; import _ from 'lodash'; +import { syncPoolTypeOnchainData } from './sync-pool-type-onchain-data'; export const addPools = async (subgraphService: V2SubgraphClient, chain: Chain): Promise => { const { block } = await subgraphService.legacyService.getMetadata(); @@ -25,9 +26,13 @@ export const addPools = async (subgraphService: V2SubgraphClient, chain: Chain): const createdPools: string[] = []; for (const subgraphPool of newPools) { - const created = await createPoolRecord(subgraphPool, chain, block.number, allNestedTypePools); - if (created) { + const dbPool = await createPoolRecord(subgraphPool, chain, block.number, allNestedTypePools); + if (dbPool) { createdPools.push(subgraphPool.id); + // When new FX pool is added, we need to get the quote token + if (subgraphPool.poolType === 'FX') { + await syncPoolTypeOnchainData([dbPool], chain); + } } } @@ -48,7 +53,7 @@ const createPoolRecord = async ( chain: Chain, blockNumber: number, nestedPools: { id: string; address: string }[], -): Promise => { +): Promise => { const poolTokens = pool.tokens || []; await prisma.prismaToken.createMany({ @@ -74,7 +79,7 @@ const createPoolRecord = async ( const prismaPoolRecordWithAssociations = subgraphToPrismaCreate(pool, chain, blockNumber, nestedPools); try { - await prisma.prismaPool.create(prismaPoolRecordWithAssociations); + const pool = await prisma.prismaPool.create(prismaPoolRecordWithAssociations); await prisma.prismaPoolTokenDynamicData.createMany({ data: poolTokens.map((token) => ({ @@ -90,11 +95,11 @@ const createPoolRecord = async ( }); await createAllTokensRelationshipForPool(pool.id, chain); + + return pool; } catch (e) { console.error(`Could not create pool ${pool.id} on chain ${chain}. Skipping.`, e); - return false; } - return true; }; const createAllTokensRelationshipForPool = async (poolId: string, chain: Chain): Promise => { diff --git a/modules/actions/pool/v2/sync-pool-type-onchain-data.ts b/modules/actions/pool/v2/sync-pool-type-onchain-data.ts new file mode 100644 index 00000000..84bb2e6b --- /dev/null +++ b/modules/actions/pool/v2/sync-pool-type-onchain-data.ts @@ -0,0 +1,64 @@ +import { Abi } from 'abitype'; +import FX from '../../../pool/abi/FxPool.json'; +import { getViemClient, ViemClient } from '../../../sources/viem-client'; +import { Chain, PrismaPoolType } from '@prisma/client'; +import { prisma } from '../../../../prisma/prisma-client'; +import { prismaBulkExecuteOperations } from '../../../../prisma/prisma-util'; + +const update = async (data: { id: string; chain: Chain; typeData: any }[]) => { + // Update the pool type data + const updates = data.map(({ id, chain, typeData }) => + prisma.prismaPool.update({ + where: { id_chain: { id, chain } }, + data: { typeData }, + }), + ); + + await prismaBulkExecuteOperations(updates, false); +}; + +export const syncPoolTypeOnchainData = async ( + pools: { id: string; chain: Chain; address: string; type: PrismaPoolType; typeData: any }[], + chain: Chain, +) => { + const viemClient = getViemClient(chain); + + // Get FX pools + const fxPools = pools.filter((pool) => pool.type === 'FX'); + const quoteTokens = await fetchFxQuoteTokens(fxPools, viemClient); + await update(quoteTokens); + + return true; +}; + +export const fetchFxQuoteTokens = async ( + pools: { id: string; chain: Chain; address: string; typeData: any }[], + viemClient: ViemClient, +) => { + // Fetch the tokens from the subgraph + const contracts = pools.map(({ address }) => { + return { + address: address as `0x${string}`, + abi: FX as Abi, + functionName: 'derivatives', + args: [1], + }; + }); + + const results = await viemClient.multicall({ contracts, allowFailure: true }); + + return results + .map((call, index) => { + // If the call failed, return null + if (call.status === 'failure') return null; + + const typeData = { ...pools[index].typeData, quoteToken: (call.result as string).toLowerCase() }; + + return { + id: pools[index].id, + chain: pools[index].chain, + typeData, + }; + }) + .filter((quoteToken): quoteToken is { id: string; chain: Chain; typeData: any } => quoteToken !== null); +}; diff --git a/modules/actions/pool/v2/sync-swaps.ts b/modules/actions/pool/v2/sync-swaps.ts index c0ea33e0..d8e84ae3 100644 --- a/modules/actions/pool/v2/sync-swaps.ts +++ b/modules/actions/pool/v2/sync-swaps.ts @@ -36,6 +36,18 @@ export async function syncSwaps(subgraphClient: V2SubgraphClient, chain: Chain): }, }); + // Get list of FX pool addresses for the fee calculation + const fxPools = (await prisma.prismaPool.findMany({ + where: { + chain: chain, + type: 'FX', + }, + select: { + id: true, + typeData: true, // contains the quote token address + }, + })) as { id: string; typeData: { quoteToken: string } }[]; + // Querying by timestamp of Fantom, because it has events without a block number in the DB const where = latestEvent ? chain === Chain.FANTOM @@ -47,14 +59,14 @@ export async function syncSwaps(subgraphClient: V2SubgraphClient, chain: Chain): console.time('BalancerSwaps'); const { swaps } = await subgraphClient.BalancerSwaps({ first: 1000, - where, + where: where, orderBy: chain === Chain.FANTOM ? Swap_OrderBy.Timestamp : Swap_OrderBy.Block, orderDirection: OrderDirection.Asc, }); console.timeEnd('BalancerSwaps'); console.time('swapV2Transformer'); - const dbSwaps = swaps.map((swap) => swapV2Transformer(swap, chain)); + const dbSwaps = swaps.map((swap) => swapV2Transformer(swap, chain, fxPools)); console.timeEnd('swapV2Transformer'); // TODO: parse batchSwaps, if needed diff --git a/modules/controllers/fx-pools-controller.ts b/modules/controllers/fx-pools-controller.ts index 64a95346..2d15a46e 100644 --- a/modules/controllers/fx-pools-controller.ts +++ b/modules/controllers/fx-pools-controller.ts @@ -1,4 +1,6 @@ import config from '../../config'; +import { prisma } from '../../prisma/prisma-client'; +import { syncPoolTypeOnchainData } from '../actions/pool/v2/sync-pool-type-onchain-data'; import { syncLatestFXPrices } from '../token/latest-fx-price'; import { Chain } from '@prisma/client'; @@ -11,5 +13,12 @@ export function FXPoolsController() { return syncLatestFXPrices(balancer, chain); }, + async syncQuoteTokens(chain: Chain) { + const pools = await prisma.prismaPool.findMany({ + where: { chain, type: 'FX' }, + }); + + return syncPoolTypeOnchainData(pools, chain); + }, }; } diff --git a/modules/pool/pool.gql b/modules/pool/pool.gql index 1db24bd2..f6fb24f9 100644 --- a/modules/pool/pool.gql +++ b/modules/pool/pool.gql @@ -90,6 +90,7 @@ extend type Mutation { poolLoadOnChainDataForAllPools(chains: [GqlChain!]!): [GqlPoolMutationResult!]! poolReloadPools(chains: [GqlChain!]!): [GqlPoolMutationResult!]! poolSyncAllCowSnapshots(chains: [GqlChain!]!): [GqlPoolMutationResult!]! + poolSyncFxQuoteTokens(chains: [GqlChain!]!): [GqlPoolMutationResult!]! } """ diff --git a/modules/pool/pool.resolvers.ts b/modules/pool/pool.resolvers.ts index 48c5abe6..d9ef185b 100644 --- a/modules/pool/pool.resolvers.ts +++ b/modules/pool/pool.resolvers.ts @@ -3,7 +3,13 @@ import { GqlChain, Resolvers } from '../../schema'; import { isAdminRoute } from '../auth/auth-context'; import { networkContext } from '../network/network-context.service'; import { headerChain } from '../context/header-chain'; -import { CowAmmController, EventsQueryController, SnapshotsController, PoolController } from '../controllers'; +import { + CowAmmController, + EventsQueryController, + SnapshotsController, + PoolController, + FXPoolsController, +} from '../controllers'; import { chainIdToChain } from '../network/chain-id-to-chain'; const balancerResolvers: Resolvers = { @@ -202,6 +208,23 @@ const balancerResolvers: Resolvers = { } } + return result; + }, + poolSyncFxQuoteTokens: async (parent, { chains }, context) => { + isAdminRoute(context); + + const result: { type: string; chain: GqlChain; success: boolean; error: string | undefined }[] = []; + + for (const chain of chains) { + try { + await FXPoolsController().syncQuoteTokens(chain); + result.push({ type: 'fx', chain, success: true, error: undefined }); + } catch (e) { + result.push({ type: 'fx', chain, success: false, error: `${e}` }); + console.log(`Could not sync fx quote tokens for chain ${chain}: ${e}`); + } + } + return result; }, }, diff --git a/modules/sources/enrichers/swaps-usd.ts b/modules/sources/enrichers/swaps-usd.ts index 095e72fc..d5ebc2d8 100644 --- a/modules/sources/enrichers/swaps-usd.ts +++ b/modules/sources/enrichers/swaps-usd.ts @@ -40,11 +40,12 @@ export async function swapsUsd(swaps: SwapEvent[], chain: Chain): Promise price.tokenAddress === swap.payload.tokenOut.address); const feeToken = tokenPrices.find((price) => price.tokenAddress === swap.payload.fee.address); const surplusToken = tokenPrices.find((price) => price.tokenAddress === swap.payload.surplus?.address); + const feeValueUSD = parseFloat(swap.payload.fee.amount) * (feeToken?.price || 0); const payload = { fee: { ...swap.payload.fee, - valueUSD: String((feeToken?.price || 0) * parseFloat(swap.payload.fee.amount)), + valueUSD: String(feeValueUSD > 0 ? feeValueUSD : swap.payload.fee.valueUSD), }, tokenIn: { ...swap.payload.tokenIn, diff --git a/modules/sources/transformers/swap-v2-transformer.ts b/modules/sources/transformers/swap-v2-transformer.ts index 8d680fdd..3974895e 100644 --- a/modules/sources/transformers/swap-v2-transformer.ts +++ b/modules/sources/transformers/swap-v2-transformer.ts @@ -10,14 +10,53 @@ import { SwapEvent } from '../../../prisma/prisma-types'; * @param chain * @returns */ -export function swapV2Transformer(swap: BalancerSwapFragment, chain: Chain): SwapEvent { +export function swapV2Transformer( + swap: BalancerSwapFragment, + chain: Chain, + fxPools: { id: string; typeData: { quoteToken: string } }[] = [], +): SwapEvent { // Avoiding scientific notation const feeFloat = parseFloat(swap.tokenAmountIn) * parseFloat(swap.poolId.swapFee ?? 0); - const fee = feeFloat < 1e6 ? feeFloat.toFixed(18).replace(/0+$/, '').replace(/\.$/, '') : String(feeFloat); - const feeFloatUSD = parseFloat(swap.valueUSD) * parseFloat(swap.poolId.swapFee ?? 0); - const feeUSD = + let fee = feeFloat < 1e6 ? feeFloat.toFixed(18).replace(/0+$/, '').replace(/\.$/, '') : String(feeFloat); + let feeFloatUSD = parseFloat(swap.valueUSD) * parseFloat(swap.poolId.swapFee ?? 0); + let feeUSD = feeFloatUSD < 1e6 ? feeFloatUSD.toFixed(18).replace(/0+$/, '').replace(/\.$/, '') : String(feeFloatUSD); + // FX pools have a different fee calculation + // Replica of the subgraph logic: + // https://github.com/balancer/balancer-subgraph-v2/blob/60453224453bd07a0a3a22a8ad6cc26e65fd809f/src/mappings/vault.ts#L551-L564 + if (swap.poolId.poolType === 'FX') { + // Find the pool that has the quote token + const fxPool = fxPools.find((pool) => pool.id === swap.poolId.id); + if (fxPool && [swap.tokenOut, swap.tokenIn].includes(fxPool.typeData.quoteToken)) { + const quoteTokenAddress = fxPool.typeData.quoteToken; + const baseTokenAddress = swap.tokenIn === quoteTokenAddress ? swap.tokenOut : swap.tokenIn; + let isTokenInBase = swap.tokenOut === quoteTokenAddress; + let baseToken = swap.poolId.tokens?.find(({ token }) => token.address == baseTokenAddress); + let quoteToken = swap.poolId.tokens?.find(({ token }) => token.address == quoteTokenAddress); + let baseRate = baseToken != null ? baseToken.token.latestFXPrice : null; + let quoteRate = quoteToken != null ? quoteToken.token.latestFXPrice : null; + + if (baseRate && quoteRate) { + if (isTokenInBase) { + feeFloatUSD += + parseFloat(swap.tokenAmountIn) * parseFloat(baseRate) - + parseFloat(swap.tokenAmountOut) * parseFloat(quoteRate); + // Need to set the fee in the tokenIn price, because it's later recalculated based on the DB prices + fee = String(feeFloatUSD / parseFloat(baseRate)); // fee / tokenIn price + } else { + feeFloatUSD += + parseFloat(swap.tokenAmountIn) * parseFloat(quoteRate) - + parseFloat(swap.tokenAmountOut) * parseFloat(baseRate); + // Need to set the fee in the tokenIn price, because it's later recalculated based on the DB prices + fee = String(feeFloatUSD / parseFloat(quoteRate)); // fee / tokenIn price + } + } + + feeUSD = String(feeFloatUSD); + } + } + return { id: swap.id, // tx + logIndex tx: swap.tx, diff --git a/modules/subgraphs/balancer-subgraph/balancer-subgraph-queries.graphql b/modules/subgraphs/balancer-subgraph/balancer-subgraph-queries.graphql index 158a15ee..6b051ada 100644 --- a/modules/subgraphs/balancer-subgraph/balancer-subgraph-queries.graphql +++ b/modules/subgraphs/balancer-subgraph/balancer-subgraph-queries.graphql @@ -481,6 +481,13 @@ fragment BalancerSwap on Swap { poolId { id swapFee + poolType + tokens { + token { + address + latestFXPrice + } + } } userAddress { id diff --git a/modules/subgraphs/balancer-subgraph/generated/balancer-subgraph-types.ts b/modules/subgraphs/balancer-subgraph/generated/balancer-subgraph-types.ts index c54a116c..17182c37 100644 --- a/modules/subgraphs/balancer-subgraph/generated/balancer-subgraph-types.ts +++ b/modules/subgraphs/balancer-subgraph/generated/balancer-subgraph-types.ts @@ -6744,7 +6744,19 @@ export type BalancerSwapsQuery = { tx: string; valueUSD: string; block?: string | null | undefined; - poolId: { __typename?: 'Pool'; id: string; swapFee: string }; + poolId: { + __typename?: 'Pool'; + id: string; + swapFee: string; + poolType?: string | null | undefined; + tokens?: + | Array<{ + __typename?: 'PoolToken'; + token: { __typename?: 'Token'; address: string; latestFXPrice?: string | null | undefined }; + }> + | null + | undefined; + }; userAddress: { __typename?: 'User'; id: string }; }>; }; @@ -6763,7 +6775,19 @@ export type BalancerSwapFragment = { tx: string; valueUSD: string; block?: string | null | undefined; - poolId: { __typename?: 'Pool'; id: string; swapFee: string }; + poolId: { + __typename?: 'Pool'; + id: string; + swapFee: string; + poolType?: string | null | undefined; + tokens?: + | Array<{ + __typename?: 'PoolToken'; + token: { __typename?: 'Token'; address: string; latestFXPrice?: string | null | undefined }; + }> + | null + | undefined; + }; userAddress: { __typename?: 'User'; id: string }; }; @@ -7092,6 +7116,13 @@ export const BalancerSwapFragmentDoc = gql` poolId { id swapFee + poolType + tokens { + token { + address + latestFXPrice + } + } } userAddress { id diff --git a/schema.ts b/schema.ts index 173712a2..b541d0bd 100644 --- a/schema.ts +++ b/schema.ts @@ -2246,6 +2246,7 @@ export interface Mutation { poolReloadStakingForAllPools: Scalars['String']; poolSyncAllCowSnapshots: Array; poolSyncAllPoolsFromSubgraph: Array; + poolSyncFxQuoteTokens: Array; poolUpdateLifetimeValuesForAllPools: Scalars['String']; poolUpdateLiquidityValuesForAllPools: Scalars['String']; protocolCacheMetrics: Scalars['String']; @@ -2293,6 +2294,10 @@ export interface MutationPoolSyncAllCowSnapshotsArgs { chains: Array; } +export interface MutationPoolSyncFxQuoteTokensArgs { + chains: Array; +} + export interface MutationTokenDeleteTokenTypeArgs { tokenAddress: Scalars['String']; type: GqlTokenType; @@ -5023,6 +5028,12 @@ export type MutationResolvers< RequireFields >; poolSyncAllPoolsFromSubgraph?: Resolver, ParentType, ContextType>; + poolSyncFxQuoteTokens?: Resolver< + Array, + ParentType, + ContextType, + RequireFields + >; poolUpdateLifetimeValuesForAllPools?: Resolver; poolUpdateLiquidityValuesForAllPools?: Resolver; protocolCacheMetrics?: Resolver; diff --git a/tasks/index.ts b/tasks/index.ts index b4bac721..e0cabd30 100644 --- a/tasks/index.ts +++ b/tasks/index.ts @@ -134,6 +134,10 @@ async function run(job: string = process.argv[2], chainId: string = process.argv } else if (job === 'sync-hook-data') { return PoolController().syncHookData(chain); } + // Maintenance + else if (job === 'sync-fx-quote-tokens') { + return FXPoolsController().syncQuoteTokens(chain); + } return Promise.reject(new Error(`Unknown job: ${job}`)); }