diff --git a/modules/network/arbitrum.ts b/modules/network/arbitrum.ts index 4848390d0..2b933ed32 100644 --- a/modules/network/arbitrum.ts +++ b/modules/network/arbitrum.ts @@ -229,7 +229,7 @@ export const arbitrumNetworkConfig: NetworkConfig = { new PhantomStableAprService(), new BoostedPoolAprService(), new SwapFeeAprService(arbitrumNetworkData.balancer.swapProtocolFeePercentage), - new GaugeAprService(gaugeSubgraphService, tokenService, [arbitrumNetworkData.bal!.address]), + new GaugeAprService(tokenService, [arbitrumNetworkData.bal!.address]), ], poolStakingServices: [new GaugeStakingService(gaugeSubgraphService, arbitrumNetworkData.bal!.address)], tokenPriceHandlers: [ diff --git a/modules/network/avalanche.ts b/modules/network/avalanche.ts index ccf6ee6d4..fb0b432fa 100644 --- a/modules/network/avalanche.ts +++ b/modules/network/avalanche.ts @@ -221,7 +221,7 @@ export const avalancheNetworkConfig: NetworkConfig = { new PhantomStableAprService(), new BoostedPoolAprService(), new SwapFeeAprService(avalancheNetworkData.balancer.swapProtocolFeePercentage), - new GaugeAprService(gaugeSubgraphService, tokenService, [avalancheNetworkData.bal!.address]), + new GaugeAprService(tokenService, [avalancheNetworkData.bal!.address]), ], poolStakingServices: [new GaugeStakingService(gaugeSubgraphService, avalancheNetworkData.bal!.address)], tokenPriceHandlers: [ diff --git a/modules/network/base.ts b/modules/network/base.ts index 05e90399a..c685da88d 100644 --- a/modules/network/base.ts +++ b/modules/network/base.ts @@ -122,7 +122,7 @@ export const baseNetworkConfig: NetworkConfig = { new IbTokensAprService(baseNetworkData.ibAprConfig), new BoostedPoolAprService(), new SwapFeeAprService(baseNetworkData.balancer.swapProtocolFeePercentage), - new GaugeAprService(gaugeSubgraphService, tokenService, [baseNetworkData.bal!.address]), + new GaugeAprService(tokenService, [baseNetworkData.bal!.address]), ], poolStakingServices: [new GaugeStakingService(gaugeSubgraphService, baseNetworkData.bal!.address)], tokenPriceHandlers: [ diff --git a/modules/network/gnosis.ts b/modules/network/gnosis.ts index 30e0145bd..f5780ac4f 100644 --- a/modules/network/gnosis.ts +++ b/modules/network/gnosis.ts @@ -147,7 +147,7 @@ export const gnosisNetworkConfig: NetworkConfig = { new PhantomStableAprService(), new BoostedPoolAprService(), new SwapFeeAprService(gnosisNetworkData.balancer.swapProtocolFeePercentage), - new GaugeAprService(gaugeSubgraphService, tokenService, [gnosisNetworkData.bal!.address]), + new GaugeAprService(tokenService, [gnosisNetworkData.bal!.address]), ], poolStakingServices: [new GaugeStakingService(gaugeSubgraphService, gnosisNetworkData.bal!.address)], tokenPriceHandlers: [ diff --git a/modules/network/mainnet.ts b/modules/network/mainnet.ts index 79f54a9c8..d13c9306a 100644 --- a/modules/network/mainnet.ts +++ b/modules/network/mainnet.ts @@ -78,6 +78,7 @@ export const mainnetNetworkData: NetworkData = { gaugeControllerHelperAddress: '0x8e5698dc4897dc12243c8642e77b4f21349db97c', balancer: { vault: '0xba12222222228d8ba445958a75a0704d566bf2c8', + tokenAdmin: '0xf302f9f50958c5593770fdf4d4812309ff77414f', composableStablePoolFactories: [ '0xf9ac7b9df2b3454e841110cce5550bd5ac6f875f', '0x85a80afee867adf27b50bdb7b76da70f1e853062', @@ -364,7 +365,7 @@ export const mainnetNetworkConfig: NetworkConfig = { new PhantomStableAprService(), new BoostedPoolAprService(), new SwapFeeAprService(mainnetNetworkData.balancer.swapProtocolFeePercentage), - new GaugeAprService(gaugeSubgraphService, tokenService, [mainnetNetworkData.bal!.address]), + new GaugeAprService(tokenService, [mainnetNetworkData.bal!.address]), ], poolStakingServices: [new GaugeStakingService(gaugeSubgraphService, mainnetNetworkData.bal!.address)], tokenPriceHandlers: [ diff --git a/modules/network/network-config-types.ts b/modules/network/network-config-types.ts index f8c742b00..29c27374b 100644 --- a/modules/network/network-config-types.ts +++ b/modules/network/network-config-types.ts @@ -93,6 +93,7 @@ export interface NetworkData { gaugeControllerHelperAddress?: string; balancer: { vault: string; + tokenAdmin?: string; weightedPoolV2Factories: string[]; composableStablePoolFactories: string[]; yieldProtocolFeePercentage: number; diff --git a/modules/network/optimism.ts b/modules/network/optimism.ts index abf4e6eb7..b34672a9e 100644 --- a/modules/network/optimism.ts +++ b/modules/network/optimism.ts @@ -253,7 +253,7 @@ export const optimismNetworkConfig: NetworkConfig = { new PhantomStableAprService(), new BoostedPoolAprService(), new SwapFeeAprService(optimismNetworkData.balancer.swapProtocolFeePercentage), - new GaugeAprService(gaugeSubgraphService, tokenService, [ + new GaugeAprService(tokenService, [ optimismNetworkData.beets!.address, optimismNetworkData.bal!.address, ]), diff --git a/modules/network/polygon.ts b/modules/network/polygon.ts index bcb2459d4..d7d46c1e4 100644 --- a/modules/network/polygon.ts +++ b/modules/network/polygon.ts @@ -263,7 +263,7 @@ export const polygonNetworkConfig: NetworkConfig = { new PhantomStableAprService(), new BoostedPoolAprService(), new SwapFeeAprService(polygonNetworkData.balancer.swapProtocolFeePercentage), - new GaugeAprService(gaugeSubgraphService, tokenService, [polygonNetworkData.bal!.address]), + new GaugeAprService(tokenService, [polygonNetworkData.bal!.address]), ], poolStakingServices: [new GaugeStakingService(gaugeSubgraphService, polygonNetworkData.bal!.address)], tokenPriceHandlers: [ diff --git a/modules/network/zkevm.ts b/modules/network/zkevm.ts index 0820d2716..b87b04156 100644 --- a/modules/network/zkevm.ts +++ b/modules/network/zkevm.ts @@ -166,7 +166,7 @@ export const zkevmNetworkConfig: NetworkConfig = { new PhantomStableAprService(), new BoostedPoolAprService(), new SwapFeeAprService(zkevmNetworkData.balancer.swapProtocolFeePercentage), - new GaugeAprService(gaugeSubgraphService, tokenService, [zkevmNetworkData.bal!.address]), + new GaugeAprService(tokenService, [zkevmNetworkData.bal!.address]), ], poolStakingServices: [new GaugeStakingService(gaugeSubgraphService, zkevmNetworkData.bal!.address)], tokenPriceHandlers: [ diff --git a/modules/pool/lib/apr-data-sources/ve-bal-gauge-apr.service.ts b/modules/pool/lib/apr-data-sources/ve-bal-gauge-apr.service.ts index a83d332ac..4ad705154 100644 --- a/modules/pool/lib/apr-data-sources/ve-bal-gauge-apr.service.ts +++ b/modules/pool/lib/apr-data-sources/ve-bal-gauge-apr.service.ts @@ -1,3 +1,10 @@ +/** + * This service calculates the APR for a pool based on the gauge rewards + * + * Definitions: + * The “working supply” of the gauge - the effective total LP token amount after all deposits have been boosted. + * "Working balance" is 40% of a user balance in a gauge - used only for BAL rewards on v2 gauges on child gauges or on mainnet + */ import { PrismaPoolWithTokens } from '../../../../prisma/prisma-types'; import { PoolAprService } from '../../pool-types'; import { TokenService } from '../../../token/token.service'; @@ -6,152 +13,159 @@ import { PrismaPoolAprItem, PrismaPoolAprRange, PrismaPoolAprType } from '@prism import { prisma } from '../../../../prisma/prisma-client'; import { prismaBulkExecuteOperations } from '../../../../prisma/prisma-util'; import { networkContext } from '../../../network/network-context.service'; -import { GaugeSubgraphService } from '../../../subgraphs/gauge-subgraph/gauge-subgraph.service'; export class GaugeAprService implements PoolAprService { private readonly MAX_VEBAL_BOOST = 2.5; - constructor( - private readonly gaugeSubgraphService: GaugeSubgraphService, - private readonly tokenService: TokenService, - private readonly primaryTokens: string[], - ) {} + constructor(private readonly tokenService: TokenService, private readonly primaryTokens: string[]) {} public getAprServiceName(): string { return 'GaugeAprService'; } - public async updateAprForPools(pools: PrismaPoolWithTokens[]): Promise { + public async updateAprForPools(pools: { id: string }[]): Promise { const operations: any[] = []; - const gauges = await this.gaugeSubgraphService.getAllGaugesWithStatus(); - const tokenPrices = await this.tokenService.getTokenPrices(); - const poolsExpanded = await prisma.prismaPool.findMany({ - where: { chain: networkContext.chain, id: { in: pools.map((pool) => pool.id) } }, + // Get the data + const tokenPrices = await this.tokenService.getTokenPrices(); + const stakings = await prisma.prismaPoolStaking.findMany({ + where: { + poolId: { in: pools.map((pool) => pool.id) }, + type: 'GAUGE', + chain: networkContext.chain, + }, include: { - dynamicData: true, - staking: { + gauge: { include: { - gauge: { - include: { - rewards: true, - }, - }, + rewards: true, + }, + }, + pool: { + include: { + dynamicData: true, }, }, }, }); - for (const pool of poolsExpanded) { - let gauge; - let preferredStaking; - for (const stake of pool.staking) { - if (stake.gauge?.status === 'PREFERRED') { - preferredStaking = stake; - gauge = gauges.find( - (subgraphGauge) => - subgraphGauge.address === stake.gauge?.gaugeAddress && stake.gauge?.status === 'PREFERRED', - ); - } - } - if (!gauge || !pool.dynamicData || !preferredStaking?.gauge) { + for (const stake of stakings) { + const { pool, gauge } = stake; + + if (!gauge || !gauge.rewards || !pool.dynamicData || pool.dynamicData.totalShares === '0') { continue; } + + // Get token rewards per year with data needed for the DB + const rewards = await Promise.allSettled( + gauge.rewards.map(async ({ tokenAddress, rewardPerSecond }) => { + const price = this.tokenService.getPriceForToken(tokenPrices, tokenAddress); + if (!price) { + return Promise.reject('Price not found'); + } + + let definition; + try { + definition = await prisma.prismaToken.findUniqueOrThrow({ + where: { address_chain: { address: tokenAddress, chain: networkContext.chain } }, + }); + } catch (e) { + //we don't have the reward token added as a token, only happens for testing tokens + return Promise.reject('Definition not found'); + } + + return { + address: tokenAddress, + symbol: definition.symbol, + rewardPerYear: parseFloat(rewardPerSecond) * secondsPerYear * price, + }; + }), + ); + + // Calculate APRs const totalShares = parseFloat(pool.dynamicData.totalShares); - const gaugeTvl = - totalShares > 0 ? (parseFloat(gauge.totalSupply) / totalShares) * pool.dynamicData.totalLiquidity : 0; - - let thirdPartyApr = 0; - - for (let rewardToken of preferredStaking.gauge.rewards) { - const tokenAddress = rewardToken.tokenAddress; - let rewardTokenDefinition; - try { - rewardTokenDefinition = await prisma.prismaToken.findUniqueOrThrow({ - where: { address_chain: { address: tokenAddress, chain: networkContext.chain } }, - }); - } catch (e) { - //we don't have the reward token added as a token, only happens for testing tokens - continue; - } - const tokenPrice = this.tokenService.getPriceForToken(tokenPrices, tokenAddress) || 0.1; - const rewardTokenPerYear = parseFloat(rewardToken.rewardPerSecond) * secondsPerYear; - const rewardTokenValuePerYear = tokenPrice * rewardTokenPerYear; - let rewardApr = gaugeTvl > 0 ? rewardTokenValuePerYear / gaugeTvl : 0; - - const isThirdPartyApr = !this.primaryTokens.includes(tokenAddress.toLowerCase()); - if (isThirdPartyApr) { - thirdPartyApr += rewardApr; - } + const bptPrice = pool.dynamicData.totalLiquidity / totalShares; + const gaugeTvl = totalShares > 0 ? parseFloat(gauge.totalSupply) * bptPrice : 0; + const workingSupply = parseFloat(gauge.workingSupply); + const workingSupplyTvl = ((workingSupply + 0.4) / 0.4) * bptPrice; - // apply vebal boost for BAL rewards on v2 gauges on child changes or on mainnet - if ( - rewardToken.tokenAddress.toLowerCase() === networkContext.data.bal!.address.toLowerCase() && - (preferredStaking.gauge.version === 2 || networkContext.chain === 'MAINNET') - ) { - const aprItemId = `${pool.id}-${rewardTokenDefinition.symbol}-apr`; - const aprRangeId = `${pool.id}-bal-apr-range`; + const aprItems = rewards + .map((reward) => { + if (reward.status === 'rejected') { + return null; + } - const itemData = { - id: aprItemId, + const { address, symbol, rewardPerYear } = reward.value; + + const itemData: PrismaPoolAprItem = { + id: `${gauge.id}-${symbol}-apr`, chain: networkContext.chain, poolId: pool.id, - title: `${rewardTokenDefinition.symbol} reward APR`, - apr: 0, - type: PrismaPoolAprType.NATIVE_REWARD, + title: `${symbol} reward APR`, group: null, + apr: 0, + type: this.primaryTokens.includes(address.toLowerCase()) + ? PrismaPoolAprType.NATIVE_REWARD + : PrismaPoolAprType.THIRD_PARTY_REWARD, }; - const rangeData = { - id: aprRangeId, - chain: networkContext.chain, - aprItemId: aprItemId, - min: rewardApr, - max: rewardApr * this.MAX_VEBAL_BOOST, - }; + // veBAL rewards have a range associated with the item + if ( + address.toLowerCase() === networkContext.data.bal!.address.toLowerCase() && + (networkContext.chain === 'MAINNET' || gauge.version === 2) + ) { + let minApr = 0; + if (networkContext.chain === 'MAINNET' && workingSupplyTvl > 0) { + minApr = rewardPerYear / workingSupplyTvl; + } else if (gaugeTvl > 0) { + minApr = rewardPerYear / gaugeTvl; + } - operations.push( - prisma.prismaPoolAprItem.upsert({ - where: { - id_chain: { - id: aprItemId, - chain: networkContext.chain, - }, - }, - update: itemData, - create: itemData, - }), - ); + const aprRangeId = `${itemData.id}-range`; + + const rangeData = { + id: aprRangeId, + chain: networkContext.chain, + aprItemId: itemData.id, + min: minApr, + max: minApr * this.MAX_VEBAL_BOOST, + }; + return [itemData, rangeData]; + } else { + itemData.apr = gaugeTvl > 0 ? rewardPerYear / gaugeTvl : 0; + + return itemData; + } + }) + .flat() + .filter((apr): apr is PrismaPoolAprItem | PrismaPoolAprRange => apr !== null); + + // Prepare DB operations + for (const item of aprItems) { + if (item.id.includes('apr-range')) { operations.push( prisma.prismaPoolAprRange.upsert({ where: { - id_chain: { id: aprRangeId, chain: networkContext.chain }, + id_chain: { id: item.id, chain: networkContext.chain }, }, - update: rangeData, - create: rangeData, + update: item, + create: item as PrismaPoolAprRange, }), ); } else { - const item: PrismaPoolAprItem = { - id: `${pool.id}-${rewardTokenDefinition.symbol}-apr`, - chain: networkContext.chain, - poolId: pool.id, - title: `${rewardTokenDefinition.symbol} reward APR`, - apr: rewardApr, - type: isThirdPartyApr ? PrismaPoolAprType.THIRD_PARTY_REWARD : PrismaPoolAprType.NATIVE_REWARD, - group: null, - }; operations.push( prisma.prismaPoolAprItem.upsert({ - where: { id_chain: { id: item.id, chain: networkContext.chain } }, + where: { + id_chain: { id: item.id, chain: networkContext.chain }, + }, update: item, - create: item, + create: item as PrismaPoolAprItem, }), ); } } } + await prismaBulkExecuteOperations(operations, true); } } diff --git a/modules/pool/lib/staking/bal-emissions.ts b/modules/pool/lib/staking/bal-emissions.ts new file mode 100644 index 000000000..e61bc7c82 --- /dev/null +++ b/modules/pool/lib/staking/bal-emissions.ts @@ -0,0 +1,95 @@ +/** + * Weekly Bal emissions are fixed / year according to: + * https://docs.google.com/spreadsheets/d/1FY0gi596YWBOTeu_mrxhWcdF74SwKMNhmu0qJVgs0KI/edit#gid=0 + * + * Using regular numbers for simplicity assuming frontend use only. + * + * Calculation source + * https://github.com/balancer-labs/balancer-v2-monorepo/blob/master/pkg/liquidity-mining/contracts/BalancerTokenAdmin.sol + */ + +export const INITIAL_RATE = 145000; +export const START_EPOCH_TIME = 1648465251; +const RATE_REDUCTION_TIME = 365 * 86400; +const RATE_REDUCTION_COEFFICIENT = 2 ** (1 / 4); + +/** + * Weekly BAL emissions + * + * @param currentTimestamp used to get the epoch + * @returns BAL emitted in a week + */ +export const weekly = ( + currentTimestamp: number = Math.round(new Date().getTime() / 1000) +): number => { + const miningEpoch = Math.floor( + (currentTimestamp - START_EPOCH_TIME) / RATE_REDUCTION_TIME + ); + + const rate = INITIAL_RATE * RATE_REDUCTION_COEFFICIENT ** -miningEpoch; + + return rate; +}; + +/** + * Total BAL emitted in epoch (1 year) + * + * @param epoch starting from 0 for the first year of emissions + * @returns BAL emitted in epoch + */ +export const total = (epoch: number): number => { + const weeklyRate = INITIAL_RATE * RATE_REDUCTION_COEFFICIENT ** -epoch; + const dailyRate = weeklyRate / 7; + + return dailyRate * 365; +}; + +/** + * Total BAL emitted between two timestamps + * + * @param start starting timestamp + * @param end ending timestamp + * @returns BAL emitted in period + */ +export const between = (start: number, end: number): number => { + if (start < START_EPOCH_TIME) { + throw 'start timestamp before emission schedule deployment'; + } + if (end < start) { + throw 'cannot finish before starting'; + } + + let totalEmissions = 0; + + const startingEpoch = Math.floor( + (start - START_EPOCH_TIME) / RATE_REDUCTION_TIME + ); + const endingEpoch = Math.floor( + (end - START_EPOCH_TIME) / RATE_REDUCTION_TIME + ); + + for ( + let currentEpoch = startingEpoch; + currentEpoch <= endingEpoch; + currentEpoch++ + ) { + totalEmissions += total(currentEpoch); + } + + // Subtract what isn't emmited within the time range + const startingEpochEnd = + START_EPOCH_TIME + RATE_REDUCTION_TIME * (startingEpoch + 1); + const endingEpochStart = START_EPOCH_TIME + RATE_REDUCTION_TIME * endingEpoch; + + const secondsInStartingEpoch = startingEpochEnd - start; + const secondsInEndingEpoch = end - endingEpochStart; + + totalEmissions -= + (total(startingEpoch) * (RATE_REDUCTION_TIME - secondsInStartingEpoch)) / + RATE_REDUCTION_TIME; + totalEmissions -= + (total(endingEpoch) * (RATE_REDUCTION_TIME - secondsInEndingEpoch)) / + RATE_REDUCTION_TIME; + + return totalEmissions; +}; diff --git a/modules/pool/lib/staking/gauge-staking.service.ts b/modules/pool/lib/staking/gauge-staking.service.ts index 3f3d27941..cf5fefb85 100644 --- a/modules/pool/lib/staking/gauge-staking.service.ts +++ b/modules/pool/lib/staking/gauge-staking.service.ts @@ -1,221 +1,306 @@ +/** + * Supports calculation of BAL and token rewards sent to gauges. + * Balancer has 3 types of gauges: + * + * 1. Mainnet gauges with working supply and relative weight + * 2. Old L2 gauges with BAL rewards sent as a reward token + * 3. New L2 gauges (aka child chain gauges) with direct BAL rewards through a streamer. + * + * Reward data is fetched onchain and stored in the DB as a token rate per second. + */ import { PoolStakingService } from '../../pool-types'; import { prisma } from '../../../../prisma/prisma-client'; import { prismaBulkExecuteOperations } from '../../../../prisma/prisma-util'; -import { PrismaPoolStakingType } from '@prisma/client'; +import { Chain, PrismaPoolStakingType } from '@prisma/client'; import { networkContext } from '../../../network/network-context.service'; import { GaugeSubgraphService, LiquidityGaugeStatus } from '../../../subgraphs/gauge-subgraph/gauge-subgraph.service'; -import { formatUnits } from 'ethers/lib/utils'; -import { getContractAt } from '../../../web3/contract'; +import type { LiquidityGauge } from '../../../subgraphs/gauge-subgraph/generated/gauge-subgraph-types'; +import gaugeControllerAbi from '../../../vebal/abi/gaugeController.json'; import childChainGaugeV2Abi from './abi/ChildChainGaugeV2.json'; import childChainGaugeV1Abi from './abi/ChildChainGaugeV1.json'; -import moment from 'moment'; -import { formatFixed } from '@ethersproject/bignumber'; +import { BigNumber } from '@ethersproject/bignumber'; +import { formatUnits } from '@ethersproject/units'; +import type { JsonFragment } from '@ethersproject/abi'; import { Multicaller3 } from '../../../web3/multicaller3'; +import { getInflationRate } from '../../../vebal/balancer-token-admin.service'; import _ from 'lodash'; -interface ChildChainInfo { +interface GaugeRate { /** 1 for old gauges, 2 for gauges receiving cross chain BAL rewards */ version: number; /** BAL per second received by the gauge */ rate: string; + // Amount of tokens staked in the gauge + totalSupply: string; + // Effective total LP token amount after all deposits have been boosted + workingSupply: string; +} + +interface GaugeRewardData { + [address: string]: { + rewardData: { + [address: string]: { + period_finish?: BigNumber; + rate?: BigNumber; + }; + }; + }; +} + +interface GaugeBalDistributionData { + [address: string]: { + rate?: BigNumber; + weight?: BigNumber; + workingSupply?: BigNumber; + totalSupply?: BigNumber; + }; } export class GaugeStakingService implements PoolStakingService { private balAddress: string; + private balMulticaller: Multicaller3; // Used to query BAL rate and gauge data + private rewardsMulticallerV1: Multicaller3; // Used to query rewards token data for v1 gauges + private rewardsMulticallerV2: Multicaller3; // Used to query rewards token data for v2 gauges + constructor(private readonly gaugeSubgraphService: GaugeSubgraphService, balAddress: string) { this.balAddress = balAddress.toLowerCase(); + + this.balMulticaller = new Multicaller3([ + ...childChainGaugeV2Abi.filter((abi) => abi.name === 'totalSupply'), + ...childChainGaugeV2Abi.filter((abi) => abi.name === 'working_supply'), + ...childChainGaugeV2Abi.filter((abi) => abi.name === 'inflation_rate'), + gaugeControllerAbi.find((abi) => abi.name === 'gauge_relative_weight'), + ] as JsonFragment[]); + + this.rewardsMulticallerV1 = new Multicaller3([ + ...childChainGaugeV1Abi.filter((abi) => abi.name === 'reward_data'), + ]); + + this.rewardsMulticallerV2 = new Multicaller3([ + ...childChainGaugeV2Abi.filter((abi) => abi.name === 'reward_data'), + ]); } - public async syncStakingForPools(): Promise { - const pools = await prisma.prismaPool.findMany({ + + async syncStakingForPools(pools?: { id: string }[]): Promise { + // Getting data from the DB and subgraph + const poolIds = (pools ?? await prisma.prismaPool.findMany({ + select: { id: true }, where: { chain: networkContext.chain }, + })).map((pool) => pool.id); + const { pools: subgraphPoolsWithGauges } = await this.gaugeSubgraphService.getPoolsWithGauges(poolIds); + + const subgraphGauges = subgraphPoolsWithGauges + .map((pool) => pool.gauges) + .flat() + .filter((gauge): gauge is LiquidityGauge => !!gauge); + + const uniquePreferentialIds = subgraphPoolsWithGauges + .filter((pool) => pool.preferentialGauge) + .map((pool) => pool.preferentialGauge!.id); + + const dbGauges = subgraphGauges.map((gauge) => ({ + id: gauge.id, + poolId: gauge.poolId!, + // we need to set the status based on the preferentialGauge entity on the gaugePool. If it's set there, it's preferential, otherwise it's active (or killed) + status: gauge.isKilled + ? 'KILLED' + : !uniquePreferentialIds.includes(gauge.id) + ? 'ACTIVE' + : ('PREFERRED' as LiquidityGaugeStatus), + version: gauge.streamer || networkContext.chain == 'MAINNET' ? 1 : (2 as 1 | 2), + tokens: gauge.tokens || [], + })); + + // Get tokens used for all reward tokens including native BAL address, which might not be on the list of tokens stored in the gauge + const prismaTokens = await prisma.prismaToken.findMany({ + where: { + address: { + in: [ + this.balAddress, + ...subgraphGauges + .map((gauge) => gauge.tokens?.map((token) => token.id.split('-')[0].toLowerCase())) + .flat() + .filter((address): address is string => !!address), + ], + }, + chain: networkContext.chain, + }, }); - const poolIds = pools.map((pool) => pool.id); - const { pools: subgraphPoolsWithGauges } = await this.gaugeSubgraphService.getPoolsWithGauges(poolIds); + const onchainRates = await this.getOnchainRewardTokensData(dbGauges); + // Prepare DB operations const operations: any[] = []; - const allGaugeAddresses = subgraphPoolsWithGauges.map((pool) => pool.gaugesList).flat(); + // DB operations for gauges + for (const gauge of dbGauges) { + operations.push( + prisma.prismaPoolStaking.upsert({ + where: { id_chain: { id: gauge.id, chain: networkContext.chain } }, + create: { + id: gauge.id, + chain: networkContext.chain, + poolId: gauge.poolId, + type: 'GAUGE', + address: gauge.id, + }, + update: {}, + }), + ); - const childChainGaugeInfo = await this.getChildChainGaugeInfo(allGaugeAddresses); + operations.push( + prisma.prismaPoolStakingGauge.upsert({ + where: { id_chain: { id: gauge.id, chain: networkContext.chain } }, + create: { + id: gauge.id, + stakingId: gauge.id, + gaugeAddress: gauge.id, + chain: networkContext.chain, + status: gauge.status, + version: gauge.version, + workingSupply: onchainRates.find(({ id }) => `${gauge.id}-${this.balAddress}` === id) + ?.workingSupply, + totalSupply: onchainRates.find(({ id }) => id.includes(gauge.id))?.totalSupply, + }, + update: { + status: gauge.status, + version: gauge.version, + workingSupply: onchainRates.find(({ id }) => `${gauge.id}-${this.balAddress}` === id) + ?.workingSupply, + totalSupply: onchainRates.find(({ id }) => id.includes(gauge.id))?.totalSupply, + }, + }), + ); + } - for (const gaugePool of subgraphPoolsWithGauges) { - const pool = pools.find((pool) => pool.id === gaugePool.poolId); - if (!pool) { + // DB operations for gauge reward tokens + for (const { id, rewardPerSecond } of onchainRates) { + const [gaugeId, tokenAddress] = id.toLowerCase().split('-'); + const token = prismaTokens.find((token) => token.address === tokenAddress); + if (!token) { + const poolId = subgraphGauges.find((gauge) => gauge.id === gaugeId)?.poolId; + console.error( + `Could not find reward token (${tokenAddress}) in DB for gauge ${gaugeId} of pool ${poolId}`, + ); continue; } - if (gaugePool.gauges) { - for (const gauge of gaugePool.gauges) { - // we need to set the status based on the preferentialGauge entity on the gaugePool. If it's set there, it's preferential, otherwise it's active (or killed) - let gaugeStatus: LiquidityGaugeStatus = 'PREFERRED'; - if (gauge.isKilled) { - gaugeStatus = 'KILLED'; - } else if (gaugePool.preferentialGauge?.id !== gauge.id) { - gaugeStatus = 'ACTIVE'; - } - - operations.push( - prisma.prismaPoolStaking.upsert({ - where: { id_chain: { id: gauge.id, chain: networkContext.chain } }, - create: { - id: gauge.id, - chain: networkContext.chain, - poolId: pool.id, - type: 'GAUGE', - address: gauge.id, - }, - update: {}, - }), - ); - const gaugeVersion = childChainGaugeInfo[gauge.id] ? childChainGaugeInfo[gauge.id].version : 1; - - operations.push( - prisma.prismaPoolStakingGauge.upsert({ - where: { id_chain: { id: gauge.id, chain: networkContext.chain } }, - create: { - id: gauge.id, - stakingId: gauge.id, - gaugeAddress: gauge.id, - chain: networkContext.chain, - status: gaugeStatus, - version: gaugeVersion, - }, - update: { - status: gaugeStatus, - version: gaugeVersion, - }, - }), - ); - - // Add BAL as a reward token for the v2 gauge - // need to add '-0' to the ID because it get's split by that further down. - if (gaugeVersion === 2) { - if (gauge.tokens) { - gauge.tokens.push({ - id: `${this.balAddress}-0`, - decimals: 18, - symbol: 'BAL', - rate: childChainGaugeInfo[gauge.id].rate, - }); - } else { - gauge.tokens = [ - { - id: `${this.balAddress}-0`, - decimals: 18, - symbol: 'BAL', - rate: childChainGaugeInfo[gauge.id].rate, - }, - ]; - } - } - if (gauge.tokens) { - const rewardTokens = await prisma.prismaToken.findMany({ - where: { - address: { in: gauge.tokens.map((token) => token.id.split('-')[0].toLowerCase()) }, - chain: networkContext.chain, - }, - }); - for (let rewardToken of gauge.tokens) { - const tokenAddress = rewardToken.id.split('-')[0].toLowerCase(); - const token = rewardTokens.find((token) => token.address === tokenAddress); - if (!token) { - console.error( - `Could not find reward token (${tokenAddress}) in DB for gauge ${gauge.id} of pool ${pool.id}`, - ); - continue; - } - - const id = `${gauge.id}-${tokenAddress}`; - - let rewardRate = '0.0'; - let periodFinish: number; - - if (gaugeVersion === 1) { - const gaugeV1 = getContractAt(gauge.id, childChainGaugeV1Abi); - const rewardData = await gaugeV1.reward_data(tokenAddress); - - periodFinish = rewardData[2]; - if (periodFinish > moment().unix()) { - // period still running - rewardRate = formatFixed(rewardData[3], token.decimals); - } - } else { - // we can't get BAL rate from the reward data but got it from the inflation_rate call which set the rewardToken.rate - if (tokenAddress === this.balAddress) { - rewardRate = rewardToken.rate ? rewardToken.rate : '0.0'; - } else { - const gaugeV2 = getContractAt(gauge.id, childChainGaugeV2Abi); - const rewardData = await gaugeV2.reward_data(tokenAddress); - - periodFinish = parseFloat(formatUnits(rewardData[1], 0)); - if (periodFinish > moment().unix()) { - // period still running - rewardRate = formatFixed(rewardData[2], token.decimals); - } - } - } - - operations.push( - prisma.prismaPoolStakingGaugeReward.upsert({ - create: { - id, - chain: networkContext.chain, - gaugeId: gauge.id, - tokenAddress: tokenAddress, - rewardPerSecond: rewardRate, - }, - update: { - rewardPerSecond: rewardRate, - }, - where: { id_chain: { id, chain: networkContext.chain } }, - }), - ); - } - } - } - } + operations.push( + prisma.prismaPoolStakingGaugeReward.upsert({ + create: { + id, + chain: networkContext.chain, + gaugeId, + tokenAddress, + rewardPerSecond, + }, + update: { + rewardPerSecond, + }, + where: { id_chain: { id, chain: networkContext.chain } }, + }), + ); } await prismaBulkExecuteOperations(operations, true, undefined); } - async getChildChainGaugeInfo(gaugeAddresses: string[]): Promise<{ [gaugeAddress: string]: ChildChainInfo }> { + private async getOnchainRewardTokensData(gauges: { id: string; version: 1 | 2; tokens: { id: string }[] }[]) { + // Get onchain data for BAL rewards const currentWeek = Math.floor(Date.now() / 1000 / 604800); - const childChainAbi = - networkContext.chain === 'MAINNET' - ? 'function inflation_rate() view returns (uint256)' - : 'function inflation_rate(uint256 week) view returns (uint256)'; - const multicall = new Multicaller3([childChainAbi]); - - let response: { [gaugeAddress: string]: ChildChainInfo } = {}; - - gaugeAddresses.forEach((address) => { - // Only L2 gauges have the inflation_rate with a week parameter - if (networkContext.chain === 'MAINNET') { - multicall.call(address, address, 'inflation_rate', [], true); - } else { - multicall.call(address, address, 'inflation_rate', [currentWeek], true); + for (const gauge of gauges) { + this.balMulticaller.call(`${gauge.id}.totalSupply`, gauge.id, 'totalSupply', [], true); + if (gauge.version === 2) { + this.balMulticaller.call(`${gauge.id}.rate`, gauge.id, 'inflation_rate', [currentWeek], true); + this.balMulticaller.call(`${gauge.id}.workingSupply`, gauge.id, 'working_supply', [], true); + } else if (networkContext.chain === Chain.MAINNET) { + this.balMulticaller.call( + `${gauge.id}.weight`, + networkContext.data.gaugeControllerAddress!, + 'gauge_relative_weight', + [gauge.id], + true, + ); + this.balMulticaller.call(`${gauge.id}.workingSupply`, gauge.id, 'working_supply', [], true); } - }); - - const childChainData = (await multicall.execute()) as Record; + } + const balData = (await this.balMulticaller.execute()) as GaugeBalDistributionData; - for (const childChainGauge in childChainData) { - if (childChainData[childChainGauge]) { - response[childChainGauge] = { - version: 2, - rate: formatUnits(childChainData[childChainGauge]!, 18), - }; - } else { - response[childChainGauge] = { - version: 1, - rate: '0.0', - }; + // Get onchain data for reward tokens + for (const gauge of gauges) { + for (const token of gauge.tokens ?? []) { + const [address] = token.id.toLowerCase().split('-'); + if (gauge.version === 1) { + this.rewardsMulticallerV1.call( + `${gauge.id}.rewardData.${address}`, + gauge.id, + 'reward_data', + [address], + true, + ); + } else { + this.rewardsMulticallerV2.call( + `${gauge.id}.rewardData.${address}`, + gauge.id, + 'reward_data', + [address], + true, + ); + } } } + const rewardsDataV1 = (await this.rewardsMulticallerV1.execute()) as GaugeRewardData; + const rewardsDataV2 = (await this.rewardsMulticallerV2.execute()) as GaugeRewardData; + const rewardsData = { ...rewardsDataV1, ...rewardsDataV2 }; + + const totalBalRate = parseFloat(formatUnits(await getInflationRate())); + const now = Math.floor(Date.now() / 1000); + + // Format onchain rates for all the rewards + const onchainRates = [ + ...Object.keys(balData).map((gaugeAddress) => { + const id = `${gaugeAddress}-${this.balAddress}`.toLowerCase(); + const { rate, weight, workingSupply, totalSupply } = balData[gaugeAddress]; + const rewardPerSecond = rate + ? formatUnits(rate) // L2 V2 case + : weight + ? (parseFloat(formatUnits(weight!)) * totalBalRate).toFixed(18) // mainnet case + : '0'; + + return { + id, + rewardPerSecond, + workingSupply: workingSupply ? formatUnits(workingSupply) : '0', + totalSupply: totalSupply ? formatUnits(totalSupply) : '0', + }; + }), + ...Object.keys(rewardsData) + .map((gaugeAddress) => [ + // L2 V1 case, includes tokens other than BAL + ...Object.keys(rewardsData[gaugeAddress].rewardData).map((tokenAddress) => { + const id = `${gaugeAddress}-${tokenAddress}`.toLowerCase(); + const { rate, period_finish } = rewardsData[gaugeAddress].rewardData[tokenAddress]; + const rewardPerSecond = + period_finish && period_finish.toNumber() > now ? formatUnits(rate!) : '0.0'; + const { totalSupply } = balData[gaugeAddress]; + + return { + id, + rewardPerSecond, + workingSupply: '0', + totalSupply: totalSupply ? formatUnits(totalSupply) : '0', + }; + }), + ]) + .flat(), + ] as { + id: string; + rewardPerSecond: string; + workingSupply: string; + totalSupply: string; + }[]; - return response; + return onchainRates; } public async reloadStakingForAllPools(stakingTypes: PrismaPoolStakingType[]): Promise { diff --git a/modules/pool/pool.gql b/modules/pool/pool.gql index 198835541..2d08af860 100644 --- a/modules/pool/pool.gql +++ b/modules/pool/pool.gql @@ -824,6 +824,7 @@ type GqlPoolStakingGauge { rewards: [GqlPoolStakingGaugeReward!]! status: GqlPoolStakingGaugeStatus! version: Int! + workingSupply: String! # There can be more than one gauge per pool, but only one preferred. For simplicity of handling, we focus on # the primary gauge. otherGauges: [GqlPoolStakingOtherGauge!] diff --git a/modules/pool/pool.prisma b/modules/pool/pool.prisma index 039eb4ae5..f31279e72 100644 --- a/modules/pool/pool.prisma +++ b/modules/pool/pool.prisma @@ -425,6 +425,8 @@ model PrismaPoolStakingGauge { rewards PrismaPoolStakingGaugeReward[] status PrismaPoolStakingGaugeStatus @default(ACTIVE) version Int @default(1) + workingSupply String @default("0.0") + totalSupply String @default("0.0") } enum PrismaPoolStakingGaugeStatus { diff --git a/modules/vebal/abi/balancerTokenAdmin.json b/modules/vebal/abi/balancerTokenAdmin.json new file mode 100644 index 000000000..2935970ce --- /dev/null +++ b/modules/vebal/abi/balancerTokenAdmin.json @@ -0,0 +1,397 @@ +[ + { + "inputs": [ + { + "internalType": "contract IVault", + "name": "vault", + "type": "address" + }, + { + "internalType": "contract IBalancerToken", + "name": "balancerToken", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "rate", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "supply", + "type": "uint256" + } + ], + "name": "MiningParametersUpdated", + "type": "event" + }, + { + "inputs": [], + "name": "INITIAL_RATE", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "RATE_DENOMINATOR", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "RATE_REDUCTION_COEFFICIENT", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "RATE_REDUCTION_TIME", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "activate", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "available_supply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "futureEpochTimeWrite", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "future_epoch_time_write", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "selector", + "type": "bytes4" + } + ], + "name": "getActionId", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getAuthorizer", + "outputs": [ + { + "internalType": "contract IAuthorizer", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getAvailableSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getBalancerToken", + "outputs": [ + { + "internalType": "contract IBalancerToken", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getFutureEpochTime", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getInflationRate", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getMiningEpoch", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getStartEpochSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getStartEpochTime", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getVault", + "outputs": [ + { + "internalType": "contract IVault", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "start", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "end", + "type": "uint256" + } + ], + "name": "mintableInTimeframe", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "start", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "end", + "type": "uint256" + } + ], + "name": "mintable_in_timeframe", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "rate", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "snapshot", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "startEpochTimeWrite", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "start_epoch_time_write", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "updateMiningParameters", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "update_mining_parameters", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] \ No newline at end of file diff --git a/modules/vebal/balancer-token-admin.service.ts b/modules/vebal/balancer-token-admin.service.ts new file mode 100644 index 000000000..9fb51ad96 --- /dev/null +++ b/modules/vebal/balancer-token-admin.service.ts @@ -0,0 +1,14 @@ +import { Contract } from '@ethersproject/contracts'; +import { BigNumber } from '@ethersproject/bignumber'; +import abi from './abi/balancerTokenAdmin.json'; +import { networkContext } from '../network/network-context.service'; + +export async function getInflationRate(): Promise { + if (networkContext.isMainnet) { + const tokenAdmin = new Contract(networkContext.data.balancer.tokenAdmin!, abi, networkContext.provider); + const inflationRate = await tokenAdmin.getInflationRate(); + return inflationRate; + } else { + return BigNumber.from(0); + } +} diff --git a/modules/vebal/prismaPoolStakingGauge.mock.ts b/modules/vebal/prismaPoolStakingGauge.mock.ts index 756d726a6..9d472c4ee 100644 --- a/modules/vebal/prismaPoolStakingGauge.mock.ts +++ b/modules/vebal/prismaPoolStakingGauge.mock.ts @@ -11,6 +11,8 @@ export function aPrismaPoolStakingGauge(...options: Partial { +// const before = Date.now() +// const result = await next(params) +// const after = Date.now() +// console.log(`Query ${params.model}.${params.action} took ${after - before}ms`) +// return result +// }) + export function setPrisma(prismaClient: PrismaClient) { prisma = prismaClient; } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4ba50cda4..3ba9f59b6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -471,6 +471,8 @@ model PrismaPoolStakingGauge { rewards PrismaPoolStakingGaugeReward[] status PrismaPoolStakingGaugeStatus @default(ACTIVE) version Int @default(1) + workingSupply String @default("0.0") + totalSupply String @default("0.0") } enum PrismaPoolStakingGaugeStatus {