diff --git a/backend/src/modules/liquid-staking/usecases/get-protocol-info.usecase.ts b/backend/src/modules/liquid-staking/usecases/get-protocol-info.usecase.ts index 8ee16fe..acfa795 100644 --- a/backend/src/modules/liquid-staking/usecases/get-protocol-info.usecase.ts +++ b/backend/src/modules/liquid-staking/usecases/get-protocol-info.usecase.ts @@ -3,14 +3,26 @@ import { getEnabledStakingPools } from "../config/staking-pools"; import { getContract } from "../../../providers/chain.provider"; import { GAUGE_ABI, POOL_ABI } from "../../../utils/abi"; import { aerodromeService } from "../../../shared/services/aerodrome.service"; +import { createCache, getCached, setCache } from "../../../shared/cache"; type DexScreenerMetrics = { feeAPR: string | null; tvlUsd: number | null; }; -/** Fetch fee-based APR and real TVL from DexScreener. */ +// Granular caches for APR/TVL and gauge data +const dexMetricsCache = createCache(); +const DEX_METRICS_TTL = 30_000; // 30s for APR + TVL + +const gaugeDataCache = createCache<{ rewardRate: bigint; totalStaked: bigint }>(); +const GAUGE_DATA_TTL = 60_000; // 60s for gauge rewards + +/** Fetch fee-based APR and real TVL from DexScreener (cached 30s). */ async function fetchDexScreenerMetrics(poolAddress: string, feeRate: number): Promise { + const cacheKey = `dex:${poolAddress.toLowerCase()}:${feeRate}`; + const cached = getCached(dexMetricsCache, cacheKey); + if (cached) return cached; + try { const res = await fetch(`https://api.dexscreener.com/latest/dex/pairs/base/${poolAddress}`, { signal: AbortSignal.timeout(8000), @@ -22,12 +34,16 @@ async function fetchDexScreenerMetrics(poolAddress: string, feeRate: number): Pr const vol24h = pair?.volume?.h24 ?? null; if (!tvlUsd || tvlUsd <= 0 || !vol24h || vol24h <= 0) { - return { feeAPR: null, tvlUsd }; + const result: DexScreenerMetrics = { feeAPR: null, tvlUsd }; + setCache(dexMetricsCache, cacheKey, result, DEX_METRICS_TTL); + return result; } const feeAPR = (vol24h * feeRate * 365) / tvlUsd * 100; console.log(`[APR-DEXSCREENER] vol24h=$${vol24h.toFixed(0)}, tvl=$${tvlUsd.toFixed(0)}, feeRate=${feeRate}, feeAPR=${feeAPR.toFixed(2)}%`); - return { feeAPR: `${feeAPR.toFixed(2)}%`, tvlUsd }; + const result: DexScreenerMetrics = { feeAPR: `${feeAPR.toFixed(2)}%`, tvlUsd }; + setCache(dexMetricsCache, cacheKey, result, DEX_METRICS_TTL); + return result; } catch (e) { console.error(`[APR-DEXSCREENER] Failed:`, e instanceof Error ? e.message : e); return { feeAPR: null, tvlUsd: null }; @@ -68,7 +84,7 @@ export interface GetProtocolInfoResponse { } let cache: { data: GetProtocolInfoResponse; expiresAt: number } | null = null; -const CACHE_TTL = 10 * 60 * 1000; // 10 minutes +const CACHE_TTL = 30_000; // 30s — individual caches (dex 30s, gauge 60s) handle per-resource freshness export async function executeGetProtocolInfo(): Promise { if (cache && Date.now() < cache.expiresAt) { @@ -88,15 +104,25 @@ export async function executeGetProtocolInfo(): Promise ); console.log(`[PROTOCOL-INFO] poolAddress=${poolAddress}, gaugeAddress=${gaugeAddress}`); - const gauge = getContract(gaugeAddress, GAUGE_ABI, "base"); const feeRate = pool.stable ? 0.0001 : 0.003; - // DexScreener is the critical path (APR + TVL). Gauge calls are best-effort — no retries. - const [dexMetrics, rewardRate, totalStaked] = await Promise.all([ - fetchDexScreenerMetrics(poolAddress, feeRate), - safeGaugeCall(gauge, "rewardRate"), - safeGaugeCall(gauge, "totalSupply"), - ]); + // Gauge data cached 60s; DexScreener cached 30s. + const gaugeCacheKey = `gauge:${gaugeAddress.toLowerCase()}`; + let gaugeData = getCached(gaugeDataCache, gaugeCacheKey); + const dexPromise = fetchDexScreenerMetrics(poolAddress, feeRate); + + if (!gaugeData) { + const gauge = getContract(gaugeAddress, GAUGE_ABI, "base"); + const [rewardRate, totalStaked] = await Promise.all([ + safeGaugeCall(gauge, "rewardRate"), + safeGaugeCall(gauge, "totalSupply"), + ]); + gaugeData = { rewardRate, totalStaked }; + setCache(gaugeDataCache, gaugeCacheKey, gaugeData, GAUGE_DATA_TTL); + } + + const dexMetrics = await dexPromise; + const { rewardRate, totalStaked } = gaugeData; let estimatedAPR = "0"; let aprSource = "unavailable"; diff --git a/backend/src/shared/cache.ts b/backend/src/shared/cache.ts new file mode 100644 index 0000000..4a7bedf --- /dev/null +++ b/backend/src/shared/cache.ts @@ -0,0 +1,35 @@ +/** + * Simple in-memory TTL cache backed by a Map. + * + * Usage: + * const myCache = createCache(); + * setCache(myCache, "key", value, 30_000); // 30s TTL + * const hit = getCached(myCache, "key"); // MyType | null + */ + +export interface CacheEntry { + value: T; + expiresAt: number; +} + +export type TTLCache = Map>; + +/** Create a new typed cache instance. */ +export function createCache(): TTLCache { + return new Map(); +} + +/** Retrieve a cached value. Returns `null` when missing or expired. */ +export function getCached(cache: TTLCache, key: string): T | null { + const entry = cache.get(key); + if (!entry || Date.now() >= entry.expiresAt) { + cache.delete(key); + return null; + } + return entry.value; +} + +/** Store a value in the cache with a TTL in milliseconds. */ +export function setCache(cache: TTLCache, key: string, value: T, ttlMs: number): void { + cache.set(key, { value, expiresAt: Date.now() + ttlMs }); +} diff --git a/backend/src/shared/services/aerodrome.service.ts b/backend/src/shared/services/aerodrome.service.ts index 21d4d7d..c6dece4 100644 --- a/backend/src/shared/services/aerodrome.service.ts +++ b/backend/src/shared/services/aerodrome.service.ts @@ -2,6 +2,7 @@ import { ethers } from "ethers"; import { getContract } from "../../providers/chain.provider"; import { getProtocolConfig, getUserAdapterAddress } from "../../config/protocols"; import { AppError } from "../errorCodes"; +import { createCache, getCached, setCache, type TTLCache } from "../cache"; import { AERODROME_ROUTER_ABI, AERODROME_FACTORY_ABI, @@ -23,22 +24,9 @@ interface Route { } const BALANCE_CACHE_TTL_MS = 90_000; -const walletBalanceCache = new Map(); - -const poolInfoCache = new Map(); - -function getCached(cache: Map, key: string): unknown | null { - const entry = cache.get(key); - if (!entry || Date.now() >= entry.expiresAt) { - cache.delete(key); - return null; - } - return entry.value; -} - -function setCache(cache: Map, key: string, value: unknown, ttlMs: number): void { - cache.set(key, { value, expiresAt: Date.now() + ttlMs }); -} +const walletBalanceCache = createCache(); +const poolInfoCache = createCache(); +const gaugeRewardCache = createCache(); function resolveTokenAddress(address: string): string { return address === ETH_ADDRESS ? WETH : address; @@ -203,8 +191,14 @@ export class AerodromeService { } async getRewardRate(gaugeAddress: string): Promise { + const cacheKey = `rewardRate:${gaugeAddress.toLowerCase()}`; + const cached = getCached(gaugeRewardCache, cacheKey); + if (cached !== null) return cached; + const gauge = getContract(gaugeAddress, GAUGE_ABI, CHAIN); - return gauge.rewardRate(); + const rate: bigint = await gauge.rewardRate(); + setCache(gaugeRewardCache, cacheKey, rate, 60_000); // 60s TTL + return rate; } // ========== ALLOWANCE ========== @@ -248,17 +242,12 @@ export class AerodromeService { getWalletBalanceCached(userAddress: string, symbol: string): string | null { const key = `${userAddress.toLowerCase()}:${symbol.toUpperCase()}`; - const cached = walletBalanceCache.get(key); - if (!cached || Date.now() >= cached.expiresAt) { - walletBalanceCache.delete(key); - return null; - } - return cached.value; + return getCached(walletBalanceCache, key); } setWalletBalanceCached(userAddress: string, symbol: string, value: string): void { const key = `${userAddress.toLowerCase()}:${symbol.toUpperCase()}`; - walletBalanceCache.set(key, { value, expiresAt: Date.now() + BALANCE_CACHE_TTL_MS }); + setCache(walletBalanceCache, key, value, BALANCE_CACHE_TTL_MS); } }