rspace-online/modules/rwallet/lib/yield-rates.ts

288 lines
8.5 KiB
TypeScript

/**
* 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<YieldOpportunity[]> {
// 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;
apyBase?: number;
apyReward?: number | null;
apyPct7D?: number;
apyMean30d?: number;
underlyingTokens?: string[];
}
const LLAMA_CHAIN_MAP: Record<string, SupportedYieldChain> = {
Ethereum: "1",
Base: "8453",
};
const LLAMA_PROJECT_MAP: Record<string, YieldProtocol> = {
"aave-v3": "aave-v3",
"morpho-blue": "morpho-blue",
};
const STABLECOIN_SYMBOLS = new Set(["USDC", "USDT", "DAI"]);
async function fetchDefiLlamaRates(): Promise<YieldOpportunity[]> {
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,
apyBase: pool.apyBase ?? undefined,
apy7d: pool.apyPct7D != null ? pool.apy + pool.apyPct7D : undefined,
apy30d: pool.apyMean30d ?? undefined,
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<Map<string, { apy: number; tvl: number }>> {
const results = new Map<string, { apy: number; tvl: number }>();
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<number | null> {
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<YieldOpportunity[]> {
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;
}