diff --git a/.nvmrc b/.nvmrc index 704a1590b..016efd8a0 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.8.0 \ No newline at end of file +v20.10.0 \ No newline at end of file diff --git a/src/api/cowcentrated/getCowMerkleCampaigns.ts b/src/api/cowcentrated/getCowMerkleCampaigns.ts index ff82957fe..08c5937d6 100644 --- a/src/api/cowcentrated/getCowMerkleCampaigns.ts +++ b/src/api/cowcentrated/getCowMerkleCampaigns.ts @@ -1,5 +1,4 @@ import { ApiChain, toChainId } from '../../utils/chain'; -import { sleep } from '../../utils/time'; import { mapValues, partition } from 'lodash'; import { isResultFulfilled } from '../../utils/promise'; import { Address, isAddressEqual } from 'viem'; @@ -9,14 +8,18 @@ import { CampaignType, CampaignTypeSetting, CowClm, + isCowClmWithRewardPool, MerklApiCampaign, MerklApiCampaignsResponse, } from './types'; import { isFiniteNumber } from '../../utils/number'; import { CachedByChain } from '../../utils/CachedByChain'; +import { CachedThrottledPromise } from '../../utils/CachedThrottledPromise'; +import { sleep } from '../../utils/time'; const INIT_DELAY = 5000; // 5 seconds const UPDATE_INTERVAL = 30 * 60 * 1000; // 30 minutes +const MIN_UPDATE_INTERVAL = 10 * 60 * 1000; // will not update more often even if update called const FRESH_LIFETIME = 30 * 60; // how many seconds is a response considered fresh for const STALE_LIFETIME = 2 * 60 * 60; // how many additional seconds can a stale response be kept const CACHE_KEY = 'COWCENTRATED_MERKL_CAMPAIGNS'; @@ -37,6 +40,7 @@ const campaignStore = new CachedByChain({ stale: STALE_LIFETIME, version: 2, // increase if the shape of Campaign[] changes }); +const updater = new CachedThrottledPromise(updateAll, MIN_UPDATE_INTERVAL); function getCampaignType(creator: Address, chain: ApiChain): CampaignType { const type = CAMPAIGN_CREATOR_TO_TYPE[creator] || 'external'; @@ -50,7 +54,7 @@ function getCampaignType(creator: Address, chain: ApiChain): CampaignType { function getCampaign( apiChain: ApiChain, campaign: MerklApiCampaign, - pools: ReadonlyArray + clms: ReadonlyArray ): Campaign | undefined { // skip campaigns with merkl test token reward if (campaign.campaignParameters.symbolRewardToken === 'aglaMerkl') { @@ -58,13 +62,21 @@ function getCampaign( } const type = getCampaignType(campaign.creator, apiChain); - const vaults = pools.filter( + const clmsForPool = clms.filter( p => p.lpAddress.toLowerCase() === campaign.mainParameter.toLowerCase() ); - if (!vaults.length) { + if (!clmsForPool.length) { return undefined; } + const vaults = clmsForPool.flatMap(clm => { + const vaults = [{ id: clm.oracleId, address: clm.address }]; + if (isCowClmWithRewardPool(clm)) { + vaults.push({ id: clm.rewardPool.oracleId, address: clm.rewardPool.address }); + } + return vaults; + }); + const vaultsWithApr = vaults.map(vault => { let totalApr: number = 0; @@ -81,8 +93,7 @@ function getCampaign( } return { - id: vault.oracleId, - address: vault.address, + ...vault, apr: totalApr, }; }); @@ -160,7 +171,7 @@ async function updateAll() { function scheduleUpdate(wait = UPDATE_INTERVAL) { sleep(wait) - .then(updateAll) + .then(() => updater.update()) .catch(err => { console.error(`> [CLM Merkl] Update all failed`, err); scheduleUpdate(); @@ -209,3 +220,9 @@ export function getCowBeefyMerklCampaignsForChain(chain: ApiChain) { return undefined; } + +/** updates merkl campaigns (if last update was more than MIN_UPDATE_INTERVAL ago) and returns campaigns that target a clm or clm pool */ +export async function updateAndGetCowMerklCampaignsForChain(chain: ApiChain) { + await updater.update(); + return getCowMerklCampaignsForChain(chain); +} diff --git a/src/api/cowcentrated/types.ts b/src/api/cowcentrated/types.ts index 6ffe5c356..9e72739b5 100644 --- a/src/api/cowcentrated/types.ts +++ b/src/api/cowcentrated/types.ts @@ -212,3 +212,5 @@ export type Campaign = { type: CampaignType; vaults: CampaignVault[]; }; + +export type CampaignForVault = Omit & CampaignVault; diff --git a/src/api/stats/common/getCowVaultApys.ts b/src/api/stats/common/getCowVaultApys.ts index 432d0432c..e19dbbca7 100644 --- a/src/api/stats/common/getCowVaultApys.ts +++ b/src/api/stats/common/getCowVaultApys.ts @@ -3,13 +3,21 @@ import { ApiChain, toChainId } from '../../../utils/chain'; import { getCowVaultsMeta } from '../../cowcentrated/getCowVaultsMeta'; import { type AnyCowClmMeta, - type CowClmMeta, + CampaignForVault, type CowClmWithRewardPoolMeta, isCowClmWithRewardPoolMeta, } from '../../cowcentrated/types'; import { isDefined } from '../../../utils/array'; import { getBeefyRewardPoolV2Apr } from './getBeefyRewardPoolV2Apr'; -import { ApyBreakdownRequest, getApyBreakdown, ApyBreakdownResult } from './getApyBreakdownNew'; +import { ApyBreakdownRequest, ApyBreakdownResult, getApyBreakdown } from './getApyBreakdownNew'; +import { updateAndGetCowMerklCampaignsForChain } from '../../cowcentrated/getCowMerkleCampaigns'; +import { getUnixTime } from 'date-fns'; +import { mapValues, omit } from 'lodash'; + +type MerklVaultData = { + totalApr: number; + campaigns: CampaignForVault[]; +}; /** * Base CLMs + Reward Pools @@ -20,9 +28,10 @@ export const getCowApys = async (apiChain: ApiChain) => { throw new Error(`No clms found for ${apiChain}`); } + const campaignByVault = await getMerklCampaignsByVault(apiChain); const chainId = toChainId(apiChain); const [clmBreakdownsResult, rewardPoolAprsResult] = await Promise.allSettled([ - getCowClmApyBreakdown(chainId, clms), + getCowClmApyBreakdown(clms, campaignByVault), getCowRewardPoolAprs(chainId, clms), ]); @@ -42,7 +51,12 @@ export const getCowApys = async (apiChain: ApiChain) => { } const rewardPoolAprs = rewardPoolAprsResult.value; - const rewardPoolBreakdowns = getCowRewardPoolApyBreakdown(clms, clmBreakdowns, rewardPoolAprs); + const rewardPoolBreakdowns = getCowRewardPoolApyBreakdown( + clms, + clmBreakdowns, + rewardPoolAprs, + campaignByVault + ); if (!rewardPoolBreakdowns) { // this just means none of the CLMs had reward pools defined in config @@ -55,10 +69,35 @@ export const getCowApys = async (apiChain: ApiChain) => { }; }; +async function getMerklCampaignsByVault( + apiChain: ApiChain +): Promise> { + const merklCampaigns = await updateAndGetCowMerklCampaignsForChain(apiChain); + const byVaultId: Record = {}; + const now = getUnixTime(new Date()); + for (const campaign of merklCampaigns.value) { + if (campaign.startTimestamp <= now && campaign.endTimestamp >= now) { + for (const vault of campaign.vaults) { + byVaultId[vault.id] ??= []; + byVaultId[vault.id].push({ + ...omit(campaign, 'vaults'), + ...vault, + }); + } + } + } + + return mapValues(byVaultId, campaigns => ({ + campaigns, + totalApr: campaigns.reduce((total, campaign) => total + campaign.apr, 0), + })); +} + function getCowRewardPoolApyBreakdown( clms: AnyCowClmMeta[], clmApys: ApyBreakdownResult, - rewardPoolAprs: (number | undefined)[] + rewardPoolAprs: (number | undefined)[], + merklById: Record ): ApyBreakdownResult | undefined { const inputs = clms .map((clm, index): ApyBreakdownRequest | undefined => { @@ -67,8 +106,8 @@ function getCowRewardPoolApyBreakdown( vaultId: clm.rewardPool.oracleId, beefyFee: 0, rewardPool: rewardPoolAprs[index], - clm: clmApys.apyBreakdowns[clm.oracleId]?.clmApr, - merkl: clmApys.apyBreakdowns[clm.oracleId]?.merklApr, + clm: clmApys.apyBreakdowns[clm.oracleId]?.clmApr, // after fee from CLM; reward pool fee = 0; so this works + merkl: merklById[clm.rewardPool.oracleId]?.totalApr || 0, // we can't copy from CLM in case it is not forwarded correctly }; } return undefined; @@ -121,66 +160,14 @@ const getCowRewardPoolApr = async ( }; const getCowClmApyBreakdown = async ( - chainId: ChainId, - vaults: AnyCowClmMeta[] + vaults: AnyCowClmMeta[], + merklById: Record ): Promise => { - const merklCampaigns = await getMerklCampaigns(chainId); return getApyBreakdown( vaults.map(vault => ({ vaultId: vault.oracleId, clm: vault.apr, - merkl: getMerklAprForVault(vault, merklCampaigns), + merkl: merklById[vault.oracleId]?.totalApr || 0, })) ); }; - -type Forwarder = { - almAPR: number; - almAddress: string; -}; - -type Campaign = { - mainParameter: string; - forwarders: Forwarder[]; -}; - -type MerklChainCampaigns = { - [poolIdentifier: string]: { - [campaignID: string]: Campaign; - }; -}; - -type MerklAPIChainCampaigns = { - [chainId in ChainId]: MerklAPIChainCampaigns; -}; - -const getMerklCampaigns = async (chainID: ChainId) => { - try { - const response = await fetch('https://api.merkl.xyz/v3/campaigns?chainIds=' + chainID).then( - res => res.json() as Promise - ); - return response[chainID]; - } catch (err) { - console.error(`> getMerklCampaigns Error on ${chainID} ${err.message}`); - console.error(err); - return {}; - } -}; - -const getMerklAprForVault = (vault: CowClmMeta, merklCampaigns: MerklChainCampaigns) => { - if (!merklCampaigns) return 0; - let apr = 0; - for (const [poolId, campaigns] of Object.entries(merklCampaigns)) { - for (const [campaignId, campaign] of Object.entries(campaigns)) { - if (campaign.mainParameter.toLowerCase() === vault.lpAddress.toLowerCase()) { - campaign.forwarders.forEach(forwarder => { - if (forwarder.almAddress.toLowerCase() === vault.address.toLowerCase()) { - if (forwarder.almAPR === 0 || isNaN(forwarder.almAPR)) return; - apr += forwarder.almAPR / 100; - } - }); - } - } - } - return apr; -}; diff --git a/src/utils/CachedByChain.ts b/src/utils/CachedByChain.ts index 854c71656..6509bf433 100644 --- a/src/utils/CachedByChain.ts +++ b/src/utils/CachedByChain.ts @@ -19,8 +19,11 @@ export type CachedByChainOptions = MemoryStoreOptions & { export type ChainMeta = { value: T; + /** unix timestamp */ updatedAt: number; + /** unix timestamp */ freshUntil: number; + /** unix timestamp */ staleUntil: number; version: number; }; diff --git a/src/utils/CachedThrottledPromise.ts b/src/utils/CachedThrottledPromise.ts new file mode 100644 index 000000000..8601d14a1 --- /dev/null +++ b/src/utils/CachedThrottledPromise.ts @@ -0,0 +1,32 @@ +export class CachedThrottledPromise { + protected inProgress: Promise | undefined; + protected lastResult: T; + protected lastUpdate: number | undefined; + + constructor(protected updateFn: () => Promise, protected minInterval: number) {} + + /** + * Returns the last result if the last update was less than minInterval ago. + */ + public async update(): Promise { + if (this.inProgress) { + return this.inProgress; + } + + const now = Date.now(); + if (this.lastUpdate && now - this.lastUpdate < this.minInterval) { + return this.lastResult; + } + + this.lastUpdate = now; + this.inProgress = this.updateFn(); + + try { + const result = await this.inProgress; + this.lastResult = result; + return result; + } finally { + this.inProgress = undefined; + } + } +}