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 @@ -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;
Expand All @@ -20,8 +21,13 @@ export interface GetPortfolioResponse {
totalPositions: number;
assets: PortfolioAsset[];
walletBalances: Record<string, string>;
stale?: boolean;
lastUpdated?: string;
}

const portfolioCache = createCache<GetPortfolioResponse>();
const PORTFOLIO_CACHE_TTL = 30_000; // 30s

async function resolvePoolAndGauge(
pool: ReturnType<typeof getEnabledStakingPools>[number]
): Promise<{ poolAddress: string; gaugeAddress: string } | null> {
Expand All @@ -33,6 +39,27 @@ async function resolvePoolAndGauge(
}

export async function executeGetPortfolio(userAddress: string): Promise<GetPortfolioResponse> {
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<GetPortfolioResponse> {
const enabledPools = getEnabledStakingPools();

// Run wallet balances, adapter lookup, and pool resolution ALL in parallel
Expand Down Expand Up @@ -142,5 +169,6 @@ export async function executeGetPortfolio(userAddress: string): Promise<GetPortf
totalPositions: assets.length,
assets,
walletBalances,
lastUpdated: new Date().toISOString(),
};
}
186 changes: 114 additions & 72 deletions backend/src/modules/liquid-staking/usecases/get-protocol-info.usecase.ts
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, getStale, 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,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 };
}
}
Expand Down Expand Up @@ -65,85 +87,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<GetProtocolInfoResponse> {
if (cache && Date.now() < cache.expiresAt) {
console.log("[PROTOCOL-INFO] Returning cached data (expires in", Math.round((cache.expiresAt - Date.now()) / 1000), "s)");
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<PoolInfo | null> => {
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<PoolInfo | null> => {
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;
}
}
46 changes: 46 additions & 0 deletions backend/src/shared/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* 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;
}

/**
* 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<T>(cache: TTLCache<T>, 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<T>(cache: TTLCache<T>, key: string, value: T, ttlMs: number): void {
cache.set(key, { value, expiresAt: Date.now() + ttlMs });
}
Loading
Loading