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

158 lines
3.9 KiB
TypeScript

/**
* Yield Sandbox — pure client-side compound interest simulator.
* No server dependencies. Uses live DeFi Llama rates when available,
* falls back to hardcoded mock rates.
*/
export type SandboxProtocol = "aave-v3" | "morpho-blue";
export type SandboxChain = "1" | "8453";
export type SandboxAsset = "USDC" | "USDT" | "DAI";
export interface YieldPoint {
days: number;
balance: number;
earnings: number;
dailyRate: number;
}
export interface SandboxResult {
principal: number;
apy: number;
isLive: boolean;
protocol: string;
chainId: string;
asset: string;
milestones: YieldPoint[];
}
export interface ProtocolComparison {
protocol: SandboxProtocol;
chainId: SandboxChain;
label: string;
apy: number;
isLive: boolean;
earnings: number;
balance: number;
}
interface LiveRate {
protocol: string;
chainId: string;
asset: string;
apy: number;
}
// ── Realistic fallback APYs (updated periodically) ──
const MOCK_FALLBACK_RATES: Record<string, number> = {
"aave-v3:1:USDC": 3.82,
"aave-v3:1:USDT": 3.61,
"aave-v3:1:DAI": 3.95,
"aave-v3:8453:USDC": 4.12,
"aave-v3:8453:DAI": 3.74,
"morpho-blue:1:USDC": 5.23,
"morpho-blue:1:USDT": 4.87,
"morpho-blue:8453:USDC": 5.52,
};
const MILESTONE_DAYS = [1, 7, 30, 90, 180, 365, 730, 1825];
const PROTOCOL_LABELS: Record<string, string> = {
"aave-v3:1": "Aave V3 Ethereum",
"aave-v3:8453": "Aave V3 Base",
"morpho-blue:1": "Morpho Ethereum",
"morpho-blue:8453": "Morpho Base",
};
// ── Core compound interest ──
export function computeYieldAtDays(
principal: number,
apy: number,
days: number,
compoundFreq = 365,
): YieldPoint {
const r = apy / 100;
const t = days / 365;
const balance = principal * Math.pow(1 + r / compoundFreq, compoundFreq * t);
const earnings = balance - principal;
const dailyRate = days > 0 ? earnings / days : 0;
return { days, balance, earnings, dailyRate };
}
// ── Full milestone simulation ──
export function simulateYield(config: {
principal: number;
apy: number;
isLive: boolean;
protocol: string;
chainId: string;
asset: string;
compoundFreq?: number;
}): SandboxResult {
const freq = config.compoundFreq ?? 365;
const milestones = MILESTONE_DAYS.map((d) =>
computeYieldAtDays(config.principal, config.apy, d, freq),
);
return {
principal: config.principal,
apy: config.apy,
isLive: config.isLive,
protocol: config.protocol,
chainId: config.chainId,
asset: config.asset,
milestones,
};
}
// ── APY resolution: live > mock ──
export function resolveApy(
protocol: string,
chainId: string,
asset: string,
liveRates?: LiveRate[],
): { apy: number; isLive: boolean } {
if (liveRates?.length) {
const match = liveRates.find(
(r) => r.protocol === protocol && r.chainId === chainId && r.asset === asset,
);
if (match && match.apy > 0) return { apy: match.apy, isLive: true };
}
const key = `${protocol}:${chainId}:${asset}`;
const fallback = MOCK_FALLBACK_RATES[key];
return fallback != null ? { apy: fallback, isLive: false } : { apy: 0, isLive: false };
}
// ── Cross-protocol comparison ──
export function buildProtocolComparisons(
asset: SandboxAsset,
principal: number,
days: number,
liveRates?: LiveRate[],
): ProtocolComparison[] {
const combos: Array<{ protocol: SandboxProtocol; chainId: SandboxChain }> = [
{ protocol: "aave-v3", chainId: "1" },
{ protocol: "aave-v3", chainId: "8453" },
{ protocol: "morpho-blue", chainId: "1" },
{ protocol: "morpho-blue", chainId: "8453" },
];
const results: ProtocolComparison[] = [];
for (const { protocol, chainId } of combos) {
const { apy, isLive } = resolveApy(protocol, chainId, asset, liveRates);
if (apy <= 0) continue;
const { balance, earnings } = computeYieldAtDays(principal, apy, days);
const label = PROTOCOL_LABELS[`${protocol}:${chainId}`] || `${protocol} ${chainId}`;
results.push({ protocol, chainId, label, apy, isLive, earnings, balance });
}
results.sort((a, b) => b.apy - a.apy);
return results;
}