Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<DexScreenerMetrics>();
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<DexScreenerMetrics> {
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),
Expand All @@ -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 };
Expand Down Expand Up @@ -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<GetProtocolInfoResponse> {
if (cache && Date.now() < cache.expiresAt) {
Expand All @@ -88,15 +104,25 @@ export async function executeGetProtocolInfo(): Promise<GetProtocolInfoResponse>
);
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";
Expand Down
35 changes: 35 additions & 0 deletions backend/src/shared/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Simple in-memory TTL cache backed by a Map.
*
* Usage:
* const myCache = createCache<MyType>();
* setCache(myCache, "key", value, 30_000); // 30s TTL
* const hit = getCached(myCache, "key"); // MyType | null
*/

export interface CacheEntry<T> {
value: T;
expiresAt: number;
}

export type TTLCache<T = unknown> = Map<string, CacheEntry<T>>;

/** Create a new typed cache instance. */
export function createCache<T = unknown>(): TTLCache<T> {
return new Map();
}

/** Retrieve a cached value. Returns `null` when missing or expired. */
export function getCached<T>(cache: TTLCache<T>, 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<T>(cache: TTLCache<T>, key: string, value: T, ttlMs: number): void {
cache.set(key, { value, expiresAt: Date.now() + ttlMs });
}
136 changes: 97 additions & 39 deletions backend/src/shared/services/aerodrome.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,22 +24,20 @@ interface Route {
}

const BALANCE_CACHE_TTL_MS = 90_000;
const walletBalanceCache = new Map<string, { value: string; expiresAt: number }>();
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<string, { value: unknown; expiresAt: number }>();
const walletBalanceCache = createCache<string>();
const poolAddressCache = createCache<string>();
const gaugeAddressCache = createCache<string>();
const tokenMetaCache = createCache<{ symbol: string; decimals: number }>();
const gaugeRewardCache = createCache<bigint>();

function getCached(cache: Map<string, { value: unknown; expiresAt: number }>, 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<string, { value: unknown; expiresAt: number }>, 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<string, Promise<string>>();
const gaugeInFlight = new Map<string, Promise<string>>();
const tokenMetaInFlight = new Map<string, Promise<{ symbol: string; decimals: number }>>();

function resolveTokenAddress(address: string): string {
return address === ETH_ADDRESS ? WETH : address;
Expand Down Expand Up @@ -110,18 +109,30 @@ export class AerodromeService {
// ========== POOL ==========

async getPoolAddress(tokenA: string, tokenB: string, stable: boolean): Promise<string> {
// 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: {
Expand Down Expand Up @@ -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<string>,
contract.decimals() as Promise<bigint>,
]);
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;
Expand All @@ -174,22 +213,40 @@ export class AerodromeService {
pool.stable() as Promise<boolean>,
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<string>,
t1.symbol() as Promise<string>,
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<string> {
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<bigint> {
Expand All @@ -203,8 +260,14 @@ export class AerodromeService {
}

async getRewardRate(gaugeAddress: string): Promise<bigint> {
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 ==========
Expand Down Expand Up @@ -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);
}
}

Expand Down
Loading