diff --git a/backend/src/modules/liquid-staking/usecases/get-portfolio.usecase.ts b/backend/src/modules/liquid-staking/usecases/get-portfolio.usecase.ts index a8eabde..1e0ca4b 100644 --- a/backend/src/modules/liquid-staking/usecases/get-portfolio.usecase.ts +++ b/backend/src/modules/liquid-staking/usecases/get-portfolio.usecase.ts @@ -4,6 +4,7 @@ import { getContract } from "../../../providers/chain.provider"; import { ERC20_ABI, POOL_ABI } from "../../../utils/abi"; import { BASE_TOKENS } from "../../../config/protocols"; import { aerodromeService } from "../../../shared/services/aerodrome.service"; +import { createCache, getCached, setCache, getStale, type TTLCache } from "../../../shared/cache"; interface PortfolioAsset { poolId: string; @@ -20,8 +21,13 @@ export interface GetPortfolioResponse { totalPositions: number; assets: PortfolioAsset[]; walletBalances: Record; + stale?: boolean; + lastUpdated?: string; } +const portfolioCache = createCache(); +const PORTFOLIO_CACHE_TTL = 30_000; // 30s + async function resolvePoolAndGauge( pool: ReturnType[number] ): Promise<{ poolAddress: string; gaugeAddress: string } | null> { @@ -33,6 +39,27 @@ async function resolvePoolAndGauge( } export async function executeGetPortfolio(userAddress: string): Promise { + const cacheKey = userAddress.toLowerCase(); + + const cached = getCached(portfolioCache, cacheKey); + if (cached) return cached; + + try { + const data = await fetchPortfolioFresh(userAddress); + setCache(portfolioCache, cacheKey, data, PORTFOLIO_CACHE_TTL); + return data; + } catch (err) { + // On failure, return stale cache if available + const stale = getStale(portfolioCache, cacheKey); + if (stale) { + console.warn(`[PORTFOLIO] Fetch failed for ${userAddress}, returning stale data from ${stale.value.lastUpdated} —`, err instanceof Error ? err.message : err); + return { ...stale.value, stale: true }; + } + throw err; + } +} + +async function fetchPortfolioFresh(userAddress: string): Promise { const enabledPools = getEnabledStakingPools(); // Run wallet balances, adapter lookup, and pool resolution ALL in parallel @@ -142,5 +169,6 @@ export async function executeGetPortfolio(userAddress: string): Promise(); +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,14 +34,24 @@ 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 stale value if available + const stale = getStale(dexMetricsCache, cacheKey); + if (stale) { + console.warn(`[APR-DEXSCREENER] Using stale cache for ${poolAddress}`); + return stale.value; + } return { feeAPR: null, tvlUsd: null }; } } @@ -65,10 +87,11 @@ export interface GetProtocolInfoResponse { chain: string; pools: PoolInfo[]; updatedAt: string; + stale?: boolean; } 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) { @@ -76,74 +99,93 @@ export async function executeGetProtocolInfo(): Promise return cache.data; } - console.log("[PROTOCOL-INFO] Cache miss — fetching fresh data from on-chain..."); - const enabledPools = getEnabledStakingPools(); - console.log("[PROTOCOL-INFO] Enabled pools:", enabledPools.map(p => p.name).join(", ")); - - const poolResults = await Promise.all(enabledPools.map(async (pool): Promise => { - try { - console.log(`[PROTOCOL-INFO] Processing pool: ${pool.name}`); - const { poolAddress, gaugeAddress } = await aerodromeService.withRetry(() => - aerodromeService.resolvePoolAndGauge(pool) - ); - 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"), - ]); - - let estimatedAPR = "0"; - let aprSource = "unavailable"; - let totalLiquidityUsd: string | null = null; - - if (dexMetrics.tvlUsd != null && Number.isFinite(dexMetrics.tvlUsd)) { - totalLiquidityUsd = dexMetrics.tvlUsd.toFixed(2); - } - - if (dexMetrics.feeAPR) { - estimatedAPR = dexMetrics.feeAPR.replace("%", ""); - aprSource = "DexScreener fee APR (24h volume × fee rate × 365 / TVL)"; - console.log(`[PROTOCOL-INFO] feeAPR=${estimatedAPR}% (DexScreener)`); + try { + console.log("[PROTOCOL-INFO] Cache miss — fetching fresh data from on-chain..."); + const enabledPools = getEnabledStakingPools(); + console.log("[PROTOCOL-INFO] Enabled pools:", enabledPools.map(p => p.name).join(", ")); + + const poolResults = await Promise.all(enabledPools.map(async (pool): Promise => { + try { + console.log(`[PROTOCOL-INFO] Processing pool: ${pool.name}`); + const { poolAddress, gaugeAddress } = await aerodromeService.withRetry(() => + aerodromeService.resolvePoolAndGauge(pool) + ); + console.log(`[PROTOCOL-INFO] poolAddress=${poolAddress}, gaugeAddress=${gaugeAddress}`); + + const feeRate = pool.stable ? 0.0001 : 0.003; + + // 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"; + let totalLiquidityUsd: string | null = null; + + if (dexMetrics.tvlUsd != null && Number.isFinite(dexMetrics.tvlUsd)) { + totalLiquidityUsd = dexMetrics.tvlUsd.toFixed(2); + } + + if (dexMetrics.feeAPR) { + estimatedAPR = dexMetrics.feeAPR.replace("%", ""); + aprSource = "DexScreener fee APR (24h volume × fee rate × 365 / TVL)"; + console.log(`[PROTOCOL-INFO] feeAPR=${estimatedAPR}% (DexScreener)`); + } + + console.log(`[PROTOCOL-INFO] estimatedAPR=${estimatedAPR}%`); + + return { + poolId: pool.id, + poolName: pool.name, + poolAddress, + gaugeAddress, + stable: pool.stable, + rewardRatePerSecond: rewardRate.toString(), + totalStaked: totalStaked.toString(), + estimatedAPR: `${estimatedAPR}%`, + aprSource, + aprDisclaimer: "Fee APR estimate only. Does not include AERO gauge rewards. Past performance is not indicative of future results.", + totalLiquidityUsd, + }; + } catch (err) { + console.error(`[PROTOCOL-INFO] Pool ${pool.name} FAILED entirely:`, err instanceof Error ? err.message : err); + return null; } - - console.log(`[PROTOCOL-INFO] estimatedAPR=${estimatedAPR}%`); - - return { - poolId: pool.id, - poolName: pool.name, - poolAddress, - gaugeAddress, - stable: pool.stable, - rewardRatePerSecond: rewardRate.toString(), - totalStaked: totalStaked.toString(), - estimatedAPR: `${estimatedAPR}%`, - aprSource, - aprDisclaimer: "Fee APR estimate only. Does not include AERO gauge rewards. Past performance is not indicative of future results.", - totalLiquidityUsd, - }; - } catch (err) { - console.error(`[PROTOCOL-INFO] Pool ${pool.name} FAILED entirely:`, err instanceof Error ? err.message : err); - return null; + })); + + const pools = poolResults.filter((p): p is PoolInfo => p !== null); + + console.log(`[PROTOCOL-INFO] Done. ${pools.length} pools resolved. APRs: ${pools.map(p => `${p.poolName}=${p.estimatedAPR}`).join(", ")}`); + + const data: GetProtocolInfoResponse = { + protocol: "Aerodrome Finance", + chain: "Base (8453)", + pools, + updatedAt: new Date().toISOString(), + }; + + cache = { data, expiresAt: Date.now() + CACHE_TTL }; + return data; + } catch (err) { + // On total failure, return stale cache if available + if (cache) { + console.warn("[PROTOCOL-INFO] Fetch failed, returning stale cache from", cache.data.updatedAt, "—", err instanceof Error ? err.message : err); + return { ...cache.data, stale: true }; } - })); - - const pools = poolResults.filter((p): p is PoolInfo => p !== null); - - console.log(`[PROTOCOL-INFO] Done. ${pools.length} pools resolved. APRs: ${pools.map(p => `${p.poolName}=${p.estimatedAPR}`).join(", ")}`); - - const data: GetProtocolInfoResponse = { - protocol: "Aerodrome Finance", - chain: "Base (8453)", - pools, - updatedAt: new Date().toISOString(), - }; - - cache = { data, expiresAt: Date.now() + CACHE_TTL }; - return data; + throw err; + } } diff --git a/backend/src/shared/cache.ts b/backend/src/shared/cache.ts new file mode 100644 index 0000000..02537a0 --- /dev/null +++ b/backend/src/shared/cache.ts @@ -0,0 +1,46 @@ +/** + * 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; +} + +/** + * Retrieve a cached value even if expired (stale fallback). + * Returns `null` only when the key was never set. + * Useful for returning last-known-good data on fetch failure. + */ +export function getStale(cache: TTLCache, key: string): { value: T; stale: boolean; expiresAt: number } | null { + const entry = cache.get(key); + if (!entry) return null; + return { value: entry.value, stale: Date.now() >= entry.expiresAt, expiresAt: entry.expiresAt }; +} + +/** 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..b48b596 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,20 @@ interface Route { } const BALANCE_CACHE_TTL_MS = 90_000; -const walletBalanceCache = new Map(); +const POOL_TTL_MS = 600_000; // 10min — pool addresses are immutable +const GAUGE_TTL_MS = 300_000; // 5min — gauge can be replaced by governance vote +const TOKEN_META_TTL_MS = 3_600_000; // 1h — symbol/decimals never change -const poolInfoCache = new Map(); +const walletBalanceCache = createCache(); +const poolAddressCache = createCache(); +const gaugeAddressCache = createCache(); +const tokenMetaCache = createCache<{ symbol: string; decimals: number }>(); +const gaugeRewardCache = createCache(); -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 }); -} +// In-flight dedup maps (same pattern as adapterCache in protocols.ts) +const poolInFlight = new Map>(); +const gaugeInFlight = new Map>(); +const tokenMetaInFlight = new Map>(); function resolveTokenAddress(address: string): string { return address === ETH_ADDRESS ? WETH : address; @@ -110,18 +109,30 @@ export class AerodromeService { // ========== POOL ========== async getPoolAddress(tokenA: string, tokenB: string, stable: boolean): Promise { - // Pool addresses are immutable — cache for 10 minutes const a = resolveTokenAddress(tokenA).toLowerCase(); const b = resolveTokenAddress(tokenB).toLowerCase(); const cacheKey = `pool:${[a, b].sort().join(":")}:${stable}`; - const cached = getCached(poolInfoCache, cacheKey); - if (cached) return cached as string; - const config = getProtocolConfig("aerodrome"); - const factory = getContract(config.contracts.factory, AERODROME_FACTORY_ABI, CHAIN); - const result: string = await factory.getPool(a, b, stable); - setCache(poolInfoCache, cacheKey, result, 600_000); // 10min - return result; + const cached = getCached(poolAddressCache, cacheKey); + if (cached) return cached; + + const inFlight = poolInFlight.get(cacheKey); + if (inFlight) return inFlight; + + const request = (async () => { + const config = getProtocolConfig("aerodrome"); + const factory = getContract(config.contracts.factory, AERODROME_FACTORY_ABI, CHAIN); + const result: string = await factory.getPool(a, b, stable); + setCache(poolAddressCache, cacheKey, result, POOL_TTL_MS); + return result; + })(); + + poolInFlight.set(cacheKey, request); + try { + return await request; + } finally { + poolInFlight.delete(cacheKey); + } } async resolvePoolAndGauge(poolConfig: { @@ -157,6 +168,34 @@ export class AerodromeService { // ========== GAUGE ========== + async getTokenMetadata(tokenAddress: string): Promise<{ symbol: string; decimals: number }> { + const key = tokenAddress.toLowerCase(); + + const cached = getCached(tokenMetaCache, key); + if (cached) return cached; + + const inFlight = tokenMetaInFlight.get(key); + if (inFlight) return inFlight; + + const request = (async () => { + const contract = getContract(tokenAddress, ERC20_ABI, CHAIN); + const [symbol, decimals] = await Promise.all([ + contract.symbol() as Promise, + contract.decimals() as Promise, + ]); + const meta = { symbol, decimals: Number(decimals) }; + setCache(tokenMetaCache, key, meta, TOKEN_META_TTL_MS); + return meta; + })(); + + tokenMetaInFlight.set(key, request); + try { + return await request; + } finally { + tokenMetaInFlight.delete(key); + } + } + async getPoolInfo(poolAddress: string): Promise<{ address: string; token0: string; @@ -174,22 +213,40 @@ export class AerodromeService { pool.stable() as Promise, pool.getReserves() as Promise<[bigint, bigint, bigint]>, ]); - const t0 = getContract(token0, ERC20_ABI, CHAIN); - const t1 = getContract(token1, ERC20_ABI, CHAIN); - const [token0Symbol, token1Symbol] = await Promise.all([ - t0.symbol() as Promise, - t1.symbol() as Promise, + const [meta0, meta1] = await Promise.all([ + this.getTokenMetadata(token0), + this.getTokenMetadata(token1), ]); return { - address: poolAddress, token0, token1, token0Symbol, token1Symbol, + address: poolAddress, token0, token1, + token0Symbol: meta0.symbol, token1Symbol: meta1.symbol, stable, reserve0: reserves[0].toString(), reserve1: reserves[1].toString(), }; } async getGaugeForPool(poolAddress: string): Promise { - const config = getProtocolConfig("aerodrome"); - const voter = getContract(config.contracts.voter, VOTER_ABI, CHAIN); - return voter.gauges(poolAddress); + const cacheKey = `gauge:${poolAddress.toLowerCase()}`; + + const cached = getCached(gaugeAddressCache, cacheKey); + if (cached) return cached; + + const inFlight = gaugeInFlight.get(cacheKey); + if (inFlight) return inFlight; + + const request = (async () => { + const config = getProtocolConfig("aerodrome"); + const voter = getContract(config.contracts.voter, VOTER_ABI, CHAIN); + const result: string = await voter.gauges(poolAddress); + setCache(gaugeAddressCache, cacheKey, result, GAUGE_TTL_MS); + return result; + })(); + + gaugeInFlight.set(cacheKey, request); + try { + return await request; + } finally { + gaugeInFlight.delete(cacheKey); + } } async getStakedBalance(gaugeAddress: string, adapterAddress: string): Promise { @@ -203,8 +260,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 +311,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); } }