288 lines
8.5 KiB
TypeScript
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;
|
|
}
|