/** * Yield rate fetching — DeFi Llama primary, Morpho GraphQL supplementary, * on-chain fallback for Aave. 5-minute in-memory cache. */ import type { YieldOpportunity, SupportedYieldChain, YieldProtocol, StablecoinSymbol } from "./yield-protocols"; import { AAVE_V3_POOL, AAVE_ATOKENS, MORPHO_VAULTS, STABLECOIN_ADDRESSES, STABLECOIN_DECIMALS, SELECTORS, padAddress, YIELD_CHAIN_NAMES, } from "./yield-protocols"; import { getRpcUrl } from "../mod"; // ── Cache ── interface CacheEntry { data: YieldOpportunity[]; timestamp: number; } const CACHE_TTL = 5 * 60 * 1000; // 5 minutes let ratesCache: CacheEntry | null = null; export async function getYieldRates(filters?: { chainId?: string; protocol?: string; asset?: string; }): Promise { // Return cached if fresh if (ratesCache && Date.now() - ratesCache.timestamp < CACHE_TTL) { return applyFilters(ratesCache.data, filters); } // Try primary source, fall back to stale cache on error try { const rates = await fetchAllRates(); ratesCache = { data: rates, timestamp: Date.now() }; return applyFilters(rates, filters); } catch (err) { console.warn("yield-rates: fetch failed, returning stale cache", err); if (ratesCache) return applyFilters(ratesCache.data, filters); return []; } } function applyFilters( rates: YieldOpportunity[], filters?: { chainId?: string; protocol?: string; asset?: string }, ): YieldOpportunity[] { if (!filters) return rates; let result = rates; if (filters.chainId) result = result.filter((r) => r.chainId === filters.chainId); if (filters.protocol) result = result.filter((r) => r.protocol === filters.protocol); if (filters.asset) result = result.filter((r) => r.asset === filters.asset); return result; } // ── Primary: DeFi Llama ── interface LlamaPool { pool: string; chain: string; project: string; symbol: string; tvlUsd: number; apy: number; apyMean7d?: number; underlyingTokens?: string[]; } const LLAMA_CHAIN_MAP: Record = { Ethereum: "1", Base: "8453", }; const LLAMA_PROJECT_MAP: Record = { "aave-v3": "aave-v3", "morpho-blue": "morpho-blue", }; const STABLECOIN_SYMBOLS = new Set(["USDC", "USDT", "DAI"]); async function fetchDefiLlamaRates(): Promise { const res = await fetch("https://yields.llama.fi/pools", { signal: AbortSignal.timeout(10000), }); if (!res.ok) throw new Error(`DeFi Llama ${res.status}`); const { data } = (await res.json()) as { data: LlamaPool[] }; const opportunities: YieldOpportunity[] = []; for (const pool of data) { const chainId = LLAMA_CHAIN_MAP[pool.chain]; const protocol = LLAMA_PROJECT_MAP[pool.project]; if (!chainId || !protocol) continue; // Match stablecoin symbol from pool symbol (e.g. "USDC", "USDC-WETH" → skip non-pure) const symbol = pool.symbol.split("-")[0] as StablecoinSymbol; if (!STABLECOIN_SYMBOLS.has(symbol)) continue; // Skip multi-asset pools if (pool.symbol.includes("-")) continue; const assetAddress = STABLECOIN_ADDRESSES[chainId]?.[symbol]; if (!assetAddress || assetAddress === "0x0000000000000000000000000000000000000000") continue; // Find vault/aToken address let vaultAddress = ""; if (protocol === "aave-v3") { vaultAddress = AAVE_ATOKENS[chainId]?.[symbol] || ""; } else { const vault = MORPHO_VAULTS[chainId]?.find((v) => v.asset === symbol); vaultAddress = vault?.address || ""; } if (!vaultAddress) continue; opportunities.push({ protocol, chainId, asset: symbol, assetAddress, vaultAddress, apy: pool.apy || 0, apy7d: pool.apyMean7d, tvl: pool.tvlUsd, poolId: pool.pool, vaultName: protocol === "morpho-blue" ? MORPHO_VAULTS[chainId]?.find((v) => v.asset === symbol)?.name : undefined, }); } return opportunities; } // ── Supplementary: Morpho GraphQL (vault-specific APY) ── async function fetchMorphoVaultRates(): Promise> { const results = new Map(); const allVaults = [ ...MORPHO_VAULTS["1"].map((v) => ({ ...v, chainId: "1" as SupportedYieldChain })), ...MORPHO_VAULTS["8453"].map((v) => ({ ...v, chainId: "8453" as SupportedYieldChain })), ]; const query = `{ vaults(where: { address_in: [${allVaults.map((v) => `"${v.address.toLowerCase()}"`).join(",")}] }) { items { address state { apy totalAssetsUsd } } } }`; try { const res = await fetch("https://blue-api.morpho.org/graphql", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query }), signal: AbortSignal.timeout(8000), }); if (!res.ok) return results; const data = await res.json() as any; for (const vault of data?.data?.vaults?.items || []) { if (vault.state) { results.set(vault.address.toLowerCase(), { apy: (vault.state.apy || 0) * 100, // Morpho returns decimal tvl: vault.state.totalAssetsUsd || 0, }); } } } catch { // Non-critical, DeFi Llama is primary } return results; } // ── Fallback: On-chain Aave getReserveData ── const RAY = 10n ** 27n; async function fetchAaveOnChainRate( chainId: SupportedYieldChain, asset: StablecoinSymbol, ): Promise { const rpcUrl = getRpcUrl(chainId); if (!rpcUrl) return null; const assetAddr = STABLECOIN_ADDRESSES[chainId]?.[asset]; if (!assetAddr) return null; const data = `${SELECTORS.getReserveData}${padAddress(assetAddr)}`; try { const res = await fetch(rpcUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_call", params: [{ to: AAVE_V3_POOL[chainId], data }, "latest"], }), signal: AbortSignal.timeout(5000), }); const result = (await res.json()).result; if (!result || result === "0x") return null; // liquidityRate is the 4th field (index 3) in the returned tuple, each 32 bytes // Struct: (uint256 config, uint128 liquidityIndex, uint128 currentLiquidityRate, ...) // Actually in Aave V3 getReserveData returns ReserveData struct where // currentLiquidityRate is at offset 0x80 (4th 32-byte slot) const hex = result.slice(2); const liquidityRateHex = hex.slice(128, 192); // 4th slot const liquidityRate = BigInt("0x" + liquidityRateHex); // Convert RAY rate to APY: ((1 + rate/RAY/SECONDS_PER_YEAR)^SECONDS_PER_YEAR - 1) * 100 // Simplified: APY ≈ (rate / RAY) * 100 (for low rates, compound effect is small) const apyApprox = Number(liquidityRate * 10000n / RAY) / 100; return apyApprox; } catch { return null; } } // ── Aggregator ── async function fetchAllRates(): Promise { const [llamaRates, morphoVaultRates] = await Promise.allSettled([ fetchDefiLlamaRates(), fetchMorphoVaultRates(), ]); let opportunities = llamaRates.status === "fulfilled" ? llamaRates.value : []; const morphoRates = morphoVaultRates.status === "fulfilled" ? morphoVaultRates.value : new Map(); // Enrich Morpho opportunities with vault-specific data for (const opp of opportunities) { if (opp.protocol === "morpho-blue") { const vaultData = morphoRates.get(opp.vaultAddress.toLowerCase()); if (vaultData) { if (vaultData.apy > 0) opp.apy = vaultData.apy; if (vaultData.tvl > 0) opp.tvl = vaultData.tvl; } } } // If DeFi Llama failed entirely, try on-chain fallback for Aave if (opportunities.length === 0) { const chains: SupportedYieldChain[] = ["1", "8453"]; const assets: StablecoinSymbol[] = ["USDC", "USDT", "DAI"]; const fallbackPromises = chains.flatMap((chainId) => assets.map(async (asset) => { const aToken = AAVE_ATOKENS[chainId]?.[asset]; const assetAddr = STABLECOIN_ADDRESSES[chainId]?.[asset]; if (!aToken || !assetAddr || assetAddr === "0x0000000000000000000000000000000000000000") return null; const apy = await fetchAaveOnChainRate(chainId, asset); if (apy === null) return null; return { protocol: "aave-v3" as YieldProtocol, chainId, asset, assetAddress: assetAddr, vaultAddress: aToken, apy, } satisfies YieldOpportunity; }), ); const results = await Promise.allSettled(fallbackPromises); for (const r of results) { if (r.status === "fulfilled" && r.value) { opportunities.push(r.value); } } } // Sort by APY descending opportunities.sort((a, b) => b.apy - a.apy); return opportunities; }