diff --git a/backend/.env.example b/backend/.env.example index 62a16b7..bc66048 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,14 +3,36 @@ PORT=3010 NODE_ENV=development # development | demo | production # ── Base Chain RPC ─────────────────────────────────────────────── +# Comma-separated, priority order. First = primary, rest = failover. +# The provider tries primary with 3.5s timeout, then races all fallbacks +# in parallel. Sick RPCs (2+ consecutive failures) are skipped for 30s. +# +# Recommended free RPCs (no API key required): +# https://base.llamarpc.com — LlamaNodes, generous limits +# https://mainnet.base.org — Coinbase official, moderate limits +# https://base.drpc.org — dRPC free tier +# https://base.meowrpc.com — MeowRPC free tier +# # Single RPC (backward-compatible): -BASE_RPC_URL=https://mainnet.base.org -# Multiple RPCs with failover (comma-separated, priority order): -# BASE_RPC_URLS=https://your-alchemy-base.com,https://base.llamarpc.com,https://mainnet.base.org +# BASE_RPC_URL=https://mainnet.base.org +# +# Multiple RPCs with failover: +BASE_RPC_URLS=https://base.llamarpc.com,https://mainnet.base.org,https://base.drpc.org # ── Avalanche Chain RPC ────────────────────────────────────────── +# Same failover logic as Base. Comma-separated, priority order. +# +# Recommended free RPCs (no API key required): +# https://api.avax.network/ext/bc/C/rpc — Official, low rate limits +# https://avalanche.drpc.org — dRPC free tier +# https://avax.meowrpc.com — MeowRPC free tier +# https://rpc.ankr.com/avalanche — Ankr public +# +# Single RPC (backward-compatible): # AVAX_RPC_URL=https://api.avax.network/ext/bc/C/rpc -# AVAX_RPC_URLS=https://your-alchemy-avax.com,https://api.avax.network/ext/bc/C/rpc,https://avalanche.drpc.org +# +# Multiple RPCs with failover: +AVAX_RPC_URLS=https://api.avax.network/ext/bc/C/rpc,https://avalanche.drpc.org,https://avax.meowrpc.com # ── Deployed Contract Addresses (Base Mainnet) ─────────────────── EXECUTOR_ADDRESS=0x82b000512A19f7B762A23033aEA5AE00aBD0D2bC diff --git a/backend/src/config/chains.ts b/backend/src/config/chains.ts index b460038..0e03f98 100644 --- a/backend/src/config/chains.ts +++ b/backend/src/config/chains.ts @@ -12,17 +12,22 @@ export interface ChainConfig { }; } -function parseRpcUrls(listEnv: string | undefined, singleEnv: string | undefined, defaultUrl: string): string[] { +function parseRpcUrls(listEnv: string | undefined, singleEnv: string | undefined, defaults: string[]): string[] { if (listEnv) { const urls = listEnv.split(",").map(u => u.trim()).filter(Boolean); if (urls.length > 0) return urls; } - return [singleEnv || defaultUrl]; + if (singleEnv) return [singleEnv]; + return defaults; } export function getChainConfig(chain: string): ChainConfig { if (chain === "base") { - const rpcUrls = parseRpcUrls(process.env.BASE_RPC_URLS, process.env.BASE_RPC_URL, "https://mainnet.base.org"); + const rpcUrls = parseRpcUrls(process.env.BASE_RPC_URLS, process.env.BASE_RPC_URL, [ + "https://base.llamarpc.com", + "https://mainnet.base.org", + "https://base.drpc.org", + ]); return { chainId: 8453, name: "Base", @@ -39,7 +44,11 @@ export function getChainConfig(chain: string): ChainConfig { } if (chain === "avalanche") { - const rpcUrls = parseRpcUrls(process.env.AVAX_RPC_URLS, process.env.AVAX_RPC_URL, "https://api.avax.network/ext/bc/C/rpc"); + const rpcUrls = parseRpcUrls(process.env.AVAX_RPC_URLS, process.env.AVAX_RPC_URL, [ + "https://api.avax.network/ext/bc/C/rpc", + "https://avalanche.drpc.org", + "https://avax.meowrpc.com", + ]); return { chainId: 43114, name: "Avalanche C-Chain", diff --git a/backend/src/config/protocols.ts b/backend/src/config/protocols.ts index 6dfa423..fac21c7 100644 --- a/backend/src/config/protocols.ts +++ b/backend/src/config/protocols.ts @@ -1,4 +1,5 @@ import { AppError } from "../shared/errorCodes"; +import { logger } from "../shared/logger"; export interface ProtocolConfig { protocolId: string; @@ -148,10 +149,7 @@ export async function getUserAdapterAddress(userAddress: string, protocolId: str adapterCache.set(cacheKey, { value: "", expiresAt: Date.now() + EMPTY_TTL_MS }); return ""; } - console.warn( - `[getUserAdapterAddress] failed:`, - err instanceof Error ? err.message : err - ); + logger.warn({ chain, protocol: protocolId, user: userAddress, error: err instanceof Error ? err.message : err }, "Adapter address lookup failed"); } adapterCache.set(cacheKey, { value: "", expiresAt: Date.now() + EMPTY_TTL_MS }); return ""; diff --git a/backend/src/index.ts b/backend/src/index.ts index 4b58ece..e2f04cd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -16,6 +16,8 @@ import { avaxLiquidStakingRoutes } from "./modules/avax-liquid-staking/routes/ import { errorHandler } from "./middleware/errorHandler"; import { rateLimiter } from "./middleware/rateLimiter"; import { serializeByUser } from "./middleware/serialize-by-user"; +import { tracingMiddleware } from "./middleware/tracing"; +import { logger } from "./shared/logger"; const app = express(); const PORT = process.env.PORT || 3010; @@ -26,12 +28,7 @@ const allowedOrigins = process.env.ALLOWED_ORIGINS : ["http://localhost:3000", "http://localhost:3010", "http://localhost:7777"]; app.use(helmet()); - -// Request logger — logs every incoming request for local debugging -app.use((req, _res, next) => { - console.log(`[execution-layer] ← ${req.method} ${req.path}`); - next(); -}); +app.use(tracingMiddleware); app.use(cors({ origin: (origin, callback) => { @@ -69,7 +66,7 @@ app.get("/health", (_req, res) => { app.use(errorHandler); app.listen(PORT, () => { - console.log(`execution-service running on port ${PORT}`); + logger.info({ port: PORT }, `execution-service running on port ${PORT}`); }); export default app; diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts index 3d13eb4..7e8caf1 100644 --- a/backend/src/middleware/errorHandler.ts +++ b/backend/src/middleware/errorHandler.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction, RequestHandler } from "express"; import { AppError } from "../shared/errorCodes"; +import { logger } from "../shared/logger"; export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction) { if (err instanceof AppError) { @@ -11,7 +12,7 @@ export function errorHandler(err: Error, _req: Request, res: Response, _next: Ne }); } - console.error("Unhandled error:", err); + logger.error({ error: err.message, stack: err.stack }, "Unhandled error"); return res.status(500).json({ error: { code: "INTERNAL_ERROR", diff --git a/backend/src/middleware/execution-timeout.ts b/backend/src/middleware/execution-timeout.ts index 655f050..b64b976 100644 --- a/backend/src/middleware/execution-timeout.ts +++ b/backend/src/middleware/execution-timeout.ts @@ -1,4 +1,5 @@ import { Request, Response, NextFunction } from "express"; +import { logger } from "../shared/logger"; // ────────────────────────────────────────────────────────────────── // EXECUTION TIMEOUT MIDDLEWARE @@ -34,9 +35,7 @@ export function executionTimeout(ms: number = DEFAULT_TIMEOUT_MS) { // Only send 504 if the response hasn't started yet. // If headers are already sent, we can't change the status code. if (!res.headersSent) { - console.error( - `[timeout] ${req.method} ${req.path} exceeded ${ms}ms — sending 504` - ); + logger.error({ method: req.method, path: req.path, timeoutMs: ms }, "Request exceeded timeout, sending 504"); res.status(504).json({ error: { code: "EXECUTION_TIMEOUT", diff --git a/backend/src/middleware/serialize-by-user.ts b/backend/src/middleware/serialize-by-user.ts index ccc9857..7676616 100644 --- a/backend/src/middleware/serialize-by-user.ts +++ b/backend/src/middleware/serialize-by-user.ts @@ -1,4 +1,5 @@ import { Request, Response, NextFunction } from "express"; +import { logger } from "../shared/logger"; // ────────────────────────────────────────────────────────────────── // CONSTANTS @@ -83,7 +84,7 @@ export function serializeByUser(req: Request, res: Response, next: NextFunction) // immediately instead of letting the queue grow unbounded. const currentDepth = queueDepth.get(userKey) ?? 0; if (currentDepth >= MAX_QUEUE_SIZE) { - console.warn(`[serialize] rejecting request for ${userKey.slice(0, 10)}… — queue full (${currentDepth})`); + logger.warn({ user: userKey.slice(0, 10), queueDepth: currentDepth }, "Rejecting request, queue full"); res.status(429).json({ error: { code: "QUEUE_FULL", diff --git a/backend/src/middleware/tracing.ts b/backend/src/middleware/tracing.ts new file mode 100644 index 0000000..069cb6d --- /dev/null +++ b/backend/src/middleware/tracing.ts @@ -0,0 +1,39 @@ +import { randomUUID } from "node:crypto"; +import type { Request, Response, NextFunction } from "express"; +import { traceStore, logger } from "../shared/logger"; + +// Extend Express Request so any handler can read req.traceId +declare global { + namespace Express { + interface Request { + traceId: string; + } + } +} + +/** + * Injects a unique traceId per request and logs method + path. + * The traceId is propagated via AsyncLocalStorage so every logger + * call within the request automatically includes it. + */ +export function tracingMiddleware(req: Request, res: Response, next: NextFunction): void { + const traceId = (req.headers["x-trace-id"] as string) || randomUUID(); + req.traceId = traceId; + res.setHeader("x-trace-id", traceId); + + const start = Date.now(); + + res.on("finish", () => { + logger.info( + { + method: req.method, + path: req.path, + status: res.statusCode, + durationMs: Date.now() - start, + }, + `${req.method} ${req.path} ${res.statusCode}`, + ); + }); + + traceStore.run({ traceId }, () => next()); +} diff --git a/backend/src/modules/avax-swap/usecases/prepare-swap.usecase.ts b/backend/src/modules/avax-swap/usecases/prepare-swap.usecase.ts index d32aa42..9efb862 100644 --- a/backend/src/modules/avax-swap/usecases/prepare-swap.usecase.ts +++ b/backend/src/modules/avax-swap/usecases/prepare-swap.usecase.ts @@ -5,6 +5,7 @@ import { applySlippage, getDeadline, encodeProtocolId } from "../../../utils/enc import { BundleBuilder, TRADERJOE_SELECTORS } from "../../../shared/bundle-builder"; import { TransactionBundle } from "../../../types/transaction"; import { AppError } from "../../../shared/errorCodes"; +import { logger } from "../../../shared/logger"; export interface PrepareAvaxSwapRequest { userAddress: string; @@ -40,17 +41,11 @@ const WAVAX_UNWRAP_ABI = ["function withdraw(uint256 wad) external"]; export async function executePrepareAvaxSwap( req: PrepareAvaxSwapRequest ): Promise { - console.log("[avax-swap] prepare request:", JSON.stringify({ - userAddress: req.userAddress, - tokenIn: req.tokenIn, - tokenOut: req.tokenOut, - amountIn: req.amountIn, - slippageBps: req.slippageBps, - })); + logger.info({ chain: "avalanche", protocol: "traderjoe", user: req.userAddress, tokenIn: req.tokenIn, tokenOut: req.tokenOut, amountIn: req.amountIn, slippageBps: req.slippageBps }, "Prepare swap request"); const chain = getChainConfig("avalanche"); const executorAddr = chain.contracts.panoramaExecutor; - console.log("[avax-swap] executorAddr:", executorAddr); + logger.info({ chain: "avalanche", executor: executorAddr }, "Executor address resolved"); if (!executorAddr) throw new AppError("INTERNAL_ERROR", "PanoramaExecutor not deployed on Avalanche"); const amountIn = BigInt(req.amountIn); @@ -97,17 +92,7 @@ export async function executePrepareAvaxSwap( const isAvaxOut = req.tokenOut.toLowerCase() === WAVAX.toLowerCase(); const swapType = isAvaxIn ? "avax-to-token" : isAvaxOut ? "token-to-avax" : "token-to-token"; - console.log("[avax-swap] quote:", { - path, - amountIn: amountIn.toString(), - amountOut: amountOut.toString(), - amountOutMin: amountOutMin.toString(), - swapType, - isAvaxIn, - isAvaxOut, - deadline, - WAVAX_const: WAVAX, - }); + logger.info({ chain: "avalanche", protocol: "traderjoe", path, amountIn: amountIn.toString(), amountOut: amountOut.toString(), amountOutMin: amountOutMin.toString(), swapType, isAvaxIn, isAvaxOut, deadline }, "Swap quote obtained"); const protocolId = encodeProtocolId("traderjoe"); const builder = new BundleBuilder(chain.chainId); @@ -138,14 +123,7 @@ export async function executePrepareAvaxSwap( [amountIn, amountOutMin, path, req.userAddress] ); - console.log("[avax-swap] adapterData:", { - selector: TRADERJOE_SELECTORS.SWAP_WITH_PATH, - protocolId, - transfers: transfers.map(t => ({ token: t.token, amount: t.amount.toString() })), - ethValue: ethValue.toString(), - executorAddr, - adapterData, - }); + logger.info({ chain: "avalanche", protocol: "traderjoe", selector: TRADERJOE_SELECTORS.SWAP_WITH_PATH, protocolId, transfers: transfers.map(t => ({ token: t.token, amount: t.amount.toString() })), ethValue: ethValue.toString() }, "Adapter data encoded"); builder.addExecute( protocolId, @@ -159,9 +137,7 @@ export async function executePrepareAvaxSwap( ); const bundle = builder.build(`Swap ${swapType} via TraderJoe on Avalanche`); - console.log("[avax-swap] bundle steps:", bundle.steps.map(s => ({ - to: s.to, value: s.value, dataLen: s.data.length, description: s.description, - }))); + logger.info({ chain: "avalanche", protocol: "traderjoe", steps: bundle.steps.map(s => ({ to: s.to, value: s.value, dataLen: s.data.length, description: s.description })) }, "Bundle built"); const priceImpact = amountIn > 0n ? (100 - (Number(amountOut) / Number(amountIn)) * 100).toFixed(4) 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..b06b06a 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,8 @@ 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"; +import { logger } from "../../../shared/logger"; interface PortfolioAsset { poolId: string; @@ -20,8 +22,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 +40,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) { + logger.warn({ protocol: "aerodrome", user: userAddress, lastUpdated: stale.value.lastUpdated, error: err instanceof Error ? err.message : err }, "Fetch failed, returning stale data"); + 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 @@ -55,15 +83,10 @@ export async function executeGetPortfolio(userAddress: string): Promise aerodromeService.getEarnedRewards(gaugeAddress, userAdapter), 2, 300).catch(() => 0n) : 0n; - console.log(`[PORTFOLIO] ${pool.name}: staked=${totalStaked}, earned=${totalEarned}`); + logger.info({ protocol: "aerodrome", pool: pool.name, staked: totalStaked.toString(), earned: totalEarned.toString() }, "Pool position fetched"); if (totalStaked > 0n) { const poolContract = getContract(poolAddress, POOL_ABI, "base"); @@ -142,5 +165,6 @@ export async function executeGetPortfolio(userAddress: string): Promise 0n); - console.log(`[POSITIONS] ${pool.name}: staked=${stakedBalance}, earned=${earnedRewards}, walletLp=${walletLpBalance}`); + logger.info({ protocol: "aerodrome", pool: pool.name, staked: stakedBalance.toString(), earned: earnedRewards.toString(), walletLp: walletLpBalance.toString() }, "Position data fetched"); if (stakedBalance > 0n || earnedRewards > 0n || walletLpBalance > 0n) { return { 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..4c17aba 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,27 @@ 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, getStale, setCache } from "../../../shared/cache"; +import { logger } from "../../../shared/logger"; 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,14 +35,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 }; + logger.info({ chain: "base", vol24h: vol24h.toFixed(0), tvl: tvlUsd.toFixed(0), feeRate, feeAPR: `${feeAPR.toFixed(2)}%` }, "DexScreener APR calculated"); + 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); + logger.error({ chain: "base", pool: poolAddress, error: e instanceof Error ? e.message : e }, "DexScreener fetch failed"); + // Return stale value if available + const stale = getStale(dexMetricsCache, cacheKey); + if (stale) { + logger.warn({ chain: "base", pool: poolAddress }, "Using stale DexScreener cache"); + return stale.value; + } return { feeAPR: null, tvlUsd: null }; } } @@ -65,85 +88,105 @@ 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) { - console.log("[PROTOCOL-INFO] Returning cached data (expires in", Math.round((cache.expiresAt - Date.now()) / 1000), "s)"); + logger.info({ protocol: "aerodrome", expiresInSec: Math.round((cache.expiresAt - Date.now()) / 1000) }, "Returning cached protocol info"); 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 { + logger.info({ protocol: "aerodrome" }, "Cache miss, fetching fresh on-chain data"); + const enabledPools = getEnabledStakingPools(); + logger.info({ protocol: "aerodrome", pools: enabledPools.map(p => p.name) }, "Enabled pools loaded"); + + const poolResults = await Promise.all(enabledPools.map(async (pool): Promise => { + try { + logger.info({ protocol: "aerodrome", pool: pool.name }, "Processing pool"); + const { poolAddress, gaugeAddress } = await aerodromeService.withRetry(() => + aerodromeService.resolvePoolAndGauge(pool) + ); + logger.info({ protocol: "aerodrome", pool: pool.name, poolAddress, gaugeAddress }, "Pool and gauge resolved"); + + 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)"; + logger.info({ protocol: "aerodrome", pool: pool.name, feeAPR: `${estimatedAPR}%` }, "Fee APR from DexScreener"); + } + + logger.info({ protocol: "aerodrome", pool: pool.name, estimatedAPR: `${estimatedAPR}%` }, "Estimated APR computed"); + + 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) { + logger.error({ protocol: "aerodrome", pool: pool.name, error: err instanceof Error ? err.message : err }, "Pool resolution failed entirely"); + 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); + + logger.info({ protocol: "aerodrome", poolCount: pools.length, aprs: pools.map(p => `${p.poolName}=${p.estimatedAPR}`) }, "Protocol info fetch complete"); + + 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) { + logger.warn({ protocol: "aerodrome", lastUpdated: cache.data.updatedAt, error: err instanceof Error ? err.message : err }, "Fetch failed, returning stale cache"); + 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/modules/liquid-staking/usecases/get-staking-pools.usecase.ts b/backend/src/modules/liquid-staking/usecases/get-staking-pools.usecase.ts index 1021aec..85cab41 100644 --- a/backend/src/modules/liquid-staking/usecases/get-staking-pools.usecase.ts +++ b/backend/src/modules/liquid-staking/usecases/get-staking-pools.usecase.ts @@ -3,6 +3,7 @@ import { getContract } from "../../../providers/chain.provider"; import { getProtocolConfig } from "../../../config/protocols"; import { GAUGE_ABI, VOTER_ABI } from "../../../utils/abi"; import { aerodromeService } from "../../../shared/services/aerodrome.service"; +import { logger } from "../../../shared/logger"; interface StakingPoolInfo { id: string; @@ -69,10 +70,7 @@ export async function executeGetStakingPools(): Promise rewardRate: rewardRate.toString(), }; } catch (err) { - console.error( - `[STAKING/POOLS] Failed to resolve pool ${pool.name}:`, - err instanceof Error ? err.message : err - ); + logger.error({ protocol: "aerodrome", pool: pool.name, error: err instanceof Error ? err.message : err }, "Failed to resolve staking pool"); return null; } })); diff --git a/backend/src/modules/liquid-staking/usecases/prepare-enter-strategy.usecase.ts b/backend/src/modules/liquid-staking/usecases/prepare-enter-strategy.usecase.ts index 9cd287c..7b99dfd 100644 --- a/backend/src/modules/liquid-staking/usecases/prepare-enter-strategy.usecase.ts +++ b/backend/src/modules/liquid-staking/usecases/prepare-enter-strategy.usecase.ts @@ -7,6 +7,7 @@ import { TransactionBundle } from "../../../types/transaction"; import { aerodromeService } from "../../../shared/services/aerodrome.service"; import { buildAerodromeAddLiquidityBundle } from "../../../shared/aerodrome-add-liquidity"; import { AppError } from "../../../shared/errorCodes"; +import { logger } from "../../../shared/logger"; export interface PrepareEnterStrategyRequest { userAddress: string; @@ -69,9 +70,7 @@ async function _executeEnterStrategyInner( const slippageBps = req.slippageBps ?? 100; const deadlineMinutes = req.deadlineMinutes ?? 20; - console.log(`[ENTER] user=${req.userAddress}, pool=${req.poolId}`); - console.log(`[ENTER] requested amountA=${amountADesired}, amountB=${amountBDesired}`); - console.log(`[ENTER] executor=${executorAddress}`); + logger.info({ protocol: "aerodrome", user: req.userAddress, pool: req.poolId, amountA: amountADesired.toString(), amountB: amountBDesired.toString(), executor: executorAddress }, "Enter strategy request"); // Cap amounts to user's actual on-chain balance to avoid TransferFromFailed. const [balA, balB] = await Promise.all([ @@ -89,8 +88,8 @@ async function _executeEnterStrategyInner( : Promise.resolve(amountBDesired), ]); - console.log(`[ENTER] ${poolConfig.tokenA.symbol} balance=${balA}, desired=${amountADesired}, capped=${amountADesired > balA} (+${Date.now() - t0}ms)`); - console.log(`[ENTER] ${poolConfig.tokenB.symbol} balance=${balB}, desired=${amountBDesired}, capped=${amountBDesired > balB}`); + logger.info({ protocol: "aerodrome", token: poolConfig.tokenA.symbol, balance: balA.toString(), desired: amountADesired.toString(), capped: amountADesired > balA, durationMs: Date.now() - t0 }, "Token A balance check"); + logger.info({ protocol: "aerodrome", token: poolConfig.tokenB.symbol, balance: balB.toString(), desired: amountBDesired.toString(), capped: amountBDesired > balB }, "Token B balance check"); if (amountADesired > balA) amountADesired = balA; if (amountBDesired > balB) amountBDesired = balB; @@ -99,7 +98,7 @@ async function _executeEnterStrategyInner( // Resolve pool and gauge addresses (hardcoded in config — instant) const { poolAddress, gaugeAddress } = await aerodromeService.resolvePoolAndGauge(poolConfig); - console.log(`[ENTER] resolvePoolAndGauge done (+${Date.now() - t0}ms) pool=${poolAddress}, gauge=${gaugeAddress}`); + logger.info({ protocol: "aerodrome", poolAddress, gaugeAddress, durationMs: Date.now() - t0 }, "Pool and gauge resolved"); // Query router for optimal amounts based on pool ratio. const { optimalA, optimalB, estimatedLiquidity } = await aerodromeService.quoteAddLiquidity( @@ -109,7 +108,7 @@ async function _executeEnterStrategyInner( amountADesired, amountBDesired ); - console.log(`[ENTER] quoteAddLiquidity done (+${Date.now() - t0}ms) optA=${optimalA}, optB=${optimalB}, liq=${estimatedLiquidity}`); + logger.info({ protocol: "aerodrome", optimalA: optimalA.toString(), optimalB: optimalB.toString(), estimatedLiquidity: estimatedLiquidity.toString(), durationMs: Date.now() - t0 }, "Liquidity quote obtained"); if (estimatedLiquidity === 0n) { throw new AppError( @@ -141,7 +140,7 @@ async function _executeEnterStrategyInner( chainId: chain.chainId, poolName: poolConfig.name, }); - console.log(`[ENTER] buildBundle done (+${Date.now() - t0}ms)`); + logger.info({ protocol: "aerodrome", durationMs: Date.now() - t0 }, "Bundle built"); return { bundle: builder.build(`Enter staking position: ${poolConfig.name}`), diff --git a/backend/src/providers/chain.provider.ts b/backend/src/providers/chain.provider.ts index a63213d..194b75a 100644 --- a/backend/src/providers/chain.provider.ts +++ b/backend/src/providers/chain.provider.ts @@ -1,5 +1,6 @@ import { ethers } from "ethers"; import { getChainConfig } from "../config/chains"; +import { logger } from "../shared/logger"; // ────────────────────────────────────────────────────────────────── // STATIC NETWORK DEFINITIONS @@ -121,7 +122,7 @@ function createProvider(chain: string): ethers.JsonRpcProvider { // Single RPC — no failover needed, return as-is if (allUrls.length <= 1) { - console.log(`[ChainProvider] ${chain}: 1 RPC endpoint (no failover)`); + logger.info({ chain, rpcEndpoint: allUrls[0] }, "Single RPC endpoint configured (no failover)"); return primary; } @@ -141,10 +142,14 @@ function createProvider(chain: string): ethers.JsonRpcProvider { return fallbackProviders; } - console.log( - `[ChainProvider] ${chain}: ${allUrls.length} RPC endpoints configured — ` + - `primary: ${allUrls[0].replace(/^https?:\/\//, "").split("/")[0]}, ` + - `fallbacks: ${fallbackUrls.map(u => u.replace(/^https?:\/\//, "").split("/")[0]).join(", ")}` + logger.info( + { + chain, + totalEndpoints: allUrls.length, + primary: allUrls[0].replace(/^https?:\/\//, "").split("/")[0], + fallbacks: fallbackUrls.map(u => u.replace(/^https?:\/\//, "").split("/")[0]), + }, + "Multiple RPC endpoints configured with failover", ); // ── Override .send() with failover logic ── @@ -163,13 +168,13 @@ function createProvider(chain: string): ethers.JsonRpcProvider { return result; } catch (err) { markRpcFailed(primaryUrl); - console.warn( - `[ChainProvider] ${chain} primary RPC failed (${method}): ` + - `${err instanceof Error ? err.message : "unknown"}` + logger.warn( + { chain, method, error: err instanceof Error ? err.message : "unknown" }, + "Primary RPC failed, falling back", ); } } else { - console.warn(`[ChainProvider] ${chain} primary RPC is sick — skipping to fallbacks`); + logger.warn({ chain, method }, "Primary RPC is sick, skipping to fallbacks"); } // ── Step 2: Race all fallbacks in parallel ── @@ -191,7 +196,7 @@ function createProvider(chain: string): ethers.JsonRpcProvider { if (!resolved) { resolved = true; markRpcHealthy(fbUrl); - console.log(`[ChainProvider] ${chain} fallback #${idx + 1} succeeded (${method})`); + logger.info({ chain, method, fallbackIndex: idx + 1 }, "Fallback RPC succeeded"); resolve(result); } }, diff --git a/backend/src/shared/aerodrome-add-liquidity.ts b/backend/src/shared/aerodrome-add-liquidity.ts index 0166e7a..b49a64b 100644 --- a/backend/src/shared/aerodrome-add-liquidity.ts +++ b/backend/src/shared/aerodrome-add-liquidity.ts @@ -2,6 +2,7 @@ import { ethers } from "ethers"; import { aerodromeService } from "./services/aerodrome.service"; import { BundleBuilder, ADAPTER_SELECTORS } from "./bundle-builder"; import { encodeProtocolId, isNativeETH, applySlippage } from "../utils/encoding"; +import { logger } from "./logger"; export interface AerodromeAddLiquidityBundleParams { userAddress: string; @@ -43,29 +44,29 @@ export async function buildAerodromeAddLiquidityBundle( const protocolId = encodeProtocolId("aerodrome"); // Check allowances in parallel — each has the provider-level 3.5s timeout. - console.log(`[ADD_LIQ] checking 3 allowances in parallel...`); + logger.debug({ protocol: "aerodrome", pool: poolName }, "Checking 3 allowances in parallel"); const [allowanceA, allowanceB, lpAllowance] = await Promise.all([ !isNativeETH(tokenA.address) ? aerodromeService.checkAllowance(tokenA.address, userAddress, executorAddress, amountADesired) .catch((e) => { - console.error(`[ADD_LIQ] allowance check ${tokenA.symbol} FAILED (+${Date.now() - t0}ms):`, e instanceof Error ? e.message : e); + logger.error({ protocol: "aerodrome", token: tokenA.symbol, durationMs: Date.now() - t0, error: e instanceof Error ? e.message : e }, "Allowance check failed"); return { allowance: 0n, sufficient: false }; }) : Promise.resolve({ allowance: ethers.MaxUint256, sufficient: true }), !isNativeETH(tokenB.address) ? aerodromeService.checkAllowance(tokenB.address, userAddress, executorAddress, amountBDesired) .catch((e) => { - console.error(`[ADD_LIQ] allowance check ${tokenB.symbol} FAILED (+${Date.now() - t0}ms):`, e instanceof Error ? e.message : e); + logger.error({ protocol: "aerodrome", token: tokenB.symbol, durationMs: Date.now() - t0, error: e instanceof Error ? e.message : e }, "Allowance check failed"); return { allowance: 0n, sufficient: false }; }) : Promise.resolve({ allowance: ethers.MaxUint256, sufficient: true }), aerodromeService.checkAllowance(poolAddress, userAddress, executorAddress, estimatedLiquidity) .catch((e) => { - console.error(`[ADD_LIQ] LP allowance check FAILED (+${Date.now() - t0}ms):`, e instanceof Error ? e.message : e); + logger.error({ protocol: "aerodrome", token: "LP", durationMs: Date.now() - t0, error: e instanceof Error ? e.message : e }, "LP allowance check failed"); return { allowance: 0n, sufficient: false }; }), ]); - console.log(`[ADD_LIQ] allowances done (+${Date.now() - t0}ms) A=${allowanceA.sufficient} B=${allowanceB.sufficient} LP=${lpAllowance.sufficient}`); + logger.info({ protocol: "aerodrome", pool: poolName, durationMs: Date.now() - t0, tokenA: allowanceA.sufficient, tokenB: allowanceB.sufficient, lp: lpAllowance.sufficient }, "Allowances checked"); // Approve tokenA / tokenB if (!isNativeETH(tokenA.address)) { diff --git a/backend/src/shared/aerodrome-swap.ts b/backend/src/shared/aerodrome-swap.ts index 5cb1fe6..94a4620 100644 --- a/backend/src/shared/aerodrome-swap.ts +++ b/backend/src/shared/aerodrome-swap.ts @@ -3,6 +3,7 @@ import { aerodromeService } from "./services/aerodrome.service"; import { BundleBuilder, ADAPTER_SELECTORS } from "./bundle-builder"; import { encodeProtocolId, isNativeETH } from "../utils/encoding"; import { AppError } from "./errorCodes"; +import { logger } from "./logger"; export interface AerodromeSwapBundleParams { userAddress: string; @@ -44,7 +45,7 @@ export async function buildAerodromeSwapBundle( const allowance = allowanceResult.status === "fulfilled" ? allowanceResult.value.allowance : 0n; if (allowanceResult.status === "rejected") { - console.warn(`[aerodrome-swap] allowance read failed — assuming 0 (will add approve step): ${(allowanceResult.reason as Error)?.message}`); + logger.warn({ protocol: "aerodrome", token: tokenIn, error: (allowanceResult.reason as Error)?.message }, "Allowance read failed, assuming 0"); } if (balanceResult.status === "fulfilled") { @@ -53,7 +54,7 @@ export async function buildAerodromeSwapBundle( throw new AppError("INSUFFICIENT_BALANCE", `Have ${balance}, need ${amountIn}`); } } else { - console.warn(`[aerodrome-swap] balance read failed — skipping check, executor will revert if insufficient: ${(balanceResult.reason as Error)?.message}`); + logger.warn({ protocol: "aerodrome", token: tokenIn, error: (balanceResult.reason as Error)?.message }, "Balance read failed, skipping check"); } builder.addApproveIfNeeded(tokenIn, executorAddress, allowance, amountIn, "Approve token for swap"); 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/logger.ts b/backend/src/shared/logger.ts new file mode 100644 index 0000000..eb0df9b --- /dev/null +++ b/backend/src/shared/logger.ts @@ -0,0 +1,111 @@ +/** + * Structured logger with per-request traceId support. + * + * - Production: JSON lines (one object per line, machine-parseable) + * - Development: human-readable colored output + * + * Usage: + * import { logger } from "../shared/logger"; + * logger.info({ chain: "base", protocol: "aerodrome" }, "Pool resolved"); + * logger.warn({ traceId: req.traceId, durationMs: 42 }, "Slow RPC"); + */ + +import { AsyncLocalStorage } from "node:async_hooks"; + +// ── Trace context ────────────────────────────────────────────────── +// AsyncLocalStorage carries the traceId through the entire request +// without having to pass it explicitly through every function call. + +export interface TraceContext { + traceId: string; +} + +export const traceStore = new AsyncLocalStorage(); + +/** Returns the current traceId or "no-trace" if outside a request. */ +export function getTraceId(): string { + return traceStore.getStore()?.traceId ?? "no-trace"; +} + +// ── Log levels ───────────────────────────────────────────────────── + +type LogLevel = "debug" | "info" | "warn" | "error"; + +const LEVEL_NUM: Record = { debug: 10, info: 20, warn: 30, error: 40 }; + +const ENV_LEVEL: LogLevel = + (process.env.LOG_LEVEL as LogLevel) ?? + (process.env.NODE_ENV === "production" ? "info" : "debug"); + +// ── Formatting ───────────────────────────────────────────────────── + +const IS_JSON = process.env.NODE_ENV === "production"; + +interface LogEntry { + level: LogLevel; + traceId: string; + msg: string; + ts: string; + [key: string]: unknown; +} + +function formatJson(entry: LogEntry): string { + return JSON.stringify(entry); +} + +const COLORS: Record = { + debug: "\x1b[90m", // gray + info: "\x1b[36m", // cyan + warn: "\x1b[33m", // yellow + error: "\x1b[31m", // red +}; +const RESET = "\x1b[0m"; + +function formatPretty(entry: LogEntry): string { + const { level, traceId, msg, ts, ...rest } = entry; + const color = COLORS[level]; + const tag = traceId !== "no-trace" ? ` [${traceId.slice(0, 8)}]` : ""; + const extra = Object.keys(rest).length > 0 + ? " " + Object.entries(rest).map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" ") + : ""; + return `${color}${level.toUpperCase().padEnd(5)}${RESET}${tag} ${msg}${extra}`; +} + +function write(level: LogLevel, fields: Record, msg: string): void { + if (LEVEL_NUM[level] < LEVEL_NUM[ENV_LEVEL]) return; + + const entry: LogEntry = { + level, + traceId: getTraceId(), + msg, + ts: new Date().toISOString(), + ...fields, + }; + + const line = IS_JSON ? formatJson(entry) : formatPretty(entry); + + if (level === "error") { + process.stderr.write(line + "\n"); + } else { + process.stdout.write(line + "\n"); + } +} + +// ── Public API ───────────────────────────────────────────────────── + +function createLogFn(level: LogLevel) { + return function log(fieldsOrMsg: Record | string, msg?: string): void { + if (typeof fieldsOrMsg === "string") { + write(level, {}, fieldsOrMsg); + } else { + write(level, fieldsOrMsg, msg ?? ""); + } + }; +} + +export const logger = { + debug: createLogFn("debug"), + info: createLogFn("info"), + warn: createLogFn("warn"), + error: createLogFn("error"), +}; 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); } }