/** * Yield strategy engine — advisory recommendations for idle stablecoin deployment. * Detects idle assets, suggests allocations, and identifies rebalance opportunities. */ import type { YieldOpportunity, YieldPosition, YieldSuggestion, SupportedYieldChain, StablecoinSymbol, } from "./yield-protocols"; import { STABLECOIN_ADDRESSES, STABLECOIN_DECIMALS, YIELD_CHAIN_NAMES } from "./yield-protocols"; import { getYieldRates } from "./yield-rates"; import { getYieldPositions } from "./yield-positions"; import { getRpcUrl } from "../mod"; // ── Strategy config ── const CONFIG = { minIdleThresholdUSD: 1000, // Don't suggest deposits below $1000 maxProtocolAllocation: 0.7, // Max 70% in one protocol minAPY: 1.0, // Skip opportunities below 1% APY minTVL: 10_000_000, // Skip pools with < $10M TVL gasCostRatioCap: 0.1, // Don't suggest if gas > 10% of annual yield rebalanceThreshold: 2.0, // Only rebalance if APY diff > 2% }; // ── Gas price estimation ── const GAS_PRICES_GWEI: Record = { "1": 30, // ~30 gwei on Ethereum "8453": 0.01, // ~0.01 gwei on Base }; const ETH_PRICE_USD = 3500; // Approximate, could fetch live const DEPOSIT_GAS = 250_000; // Approve + supply/deposit via MultiSend const WITHDRAW_GAS = 150_000; function estimateGasCostUSD(chainId: SupportedYieldChain, gasUnits: number): number { const gweiPrice = GAS_PRICES_GWEI[chainId] || 30; const ethCost = (gweiPrice * gasUnits) / 1e9; return ethCost * ETH_PRICE_USD; } // ── Idle stablecoin detection ── interface IdleBalance { chainId: SupportedYieldChain; asset: StablecoinSymbol; assetAddress: string; balance: string; balanceUSD: number; } async function rpcBalanceOf(rpcUrl: string, token: string, address: string): Promise { const paddedAddr = address.slice(2).toLowerCase().padStart(64, "0"); const data = `0x70a08231${paddedAddr}`; 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: token, data }, "latest"], }), signal: AbortSignal.timeout(5000), }); const result = (await res.json()).result; if (!result || result === "0x" || result === "0x0") return 0n; return BigInt(result); } catch { return 0n; } } export async function detectIdleStablecoins(address: string): Promise { const chains: SupportedYieldChain[] = ["1", "8453"]; const assets: StablecoinSymbol[] = ["USDC", "USDT", "DAI"]; const idle: IdleBalance[] = []; const queries = chains.flatMap((chainId) => assets.map((asset) => ({ chainId, asset })), ); await Promise.allSettled( queries.map(async ({ chainId, asset }) => { const assetAddr = STABLECOIN_ADDRESSES[chainId]?.[asset]; if (!assetAddr || assetAddr === "0x0000000000000000000000000000000000000000") return; const rpcUrl = getRpcUrl(chainId); if (!rpcUrl) return; const balance = await rpcBalanceOf(rpcUrl, assetAddr, address); if (balance === 0n) return; const decimals = STABLECOIN_DECIMALS[asset]; const balanceUSD = Number(balance) / 10 ** decimals; if (balanceUSD >= CONFIG.minIdleThresholdUSD) { idle.push({ chainId, asset, assetAddress: assetAddr, balance: balance.toString(), balanceUSD, }); } }), ); idle.sort((a, b) => b.balanceUSD - a.balanceUSD); return idle; } // ── Strategy computation ── export interface StrategyResult { idleBalances: IdleBalance[]; positions: YieldPosition[]; suggestions: YieldSuggestion[]; totalIdleUSD: number; totalDepositedUSD: number; weightedAPY: number; } export async function computeStrategy(address: string): Promise { // Fetch everything in parallel const [idle, positions, rates] = await Promise.all([ detectIdleStablecoins(address), getYieldPositions(address), getYieldRates(), ]); const totalIdleUSD = idle.reduce((sum, b) => sum + b.balanceUSD, 0); const totalDepositedUSD = positions.reduce((sum, p) => { return sum + Number(BigInt(p.underlying)) / 10 ** p.decimals; }, 0); // Weighted APY across positions let weightedAPY = 0; if (totalDepositedUSD > 0) { const weighted = positions.reduce((sum, p) => { const usd = Number(BigInt(p.underlying)) / 10 ** p.decimals; return sum + usd * p.apy; }, 0); weightedAPY = weighted / totalDepositedUSD; } const suggestions: YieldSuggestion[] = []; // Filter eligible opportunities const eligibleRates = rates.filter( (r) => r.apy >= CONFIG.minAPY && (!r.tvl || r.tvl >= CONFIG.minTVL), ); // ── Deposit suggestions (idle → yield) ── for (const idleBalance of idle) { // Find best opportunity on same chain + asset const sameChainRates = eligibleRates.filter( (r) => r.chainId === idleBalance.chainId && r.asset === idleBalance.asset, ); // Also consider cross-chain if on Ethereum (high gas) — prefer Base const bestOnChain = sameChainRates[0]; // Already sorted by APY if (!bestOnChain) continue; const gasCost = estimateGasCostUSD(idleBalance.chainId, DEPOSIT_GAS); const annualYield = idleBalance.balanceUSD * (bestOnChain.apy / 100); // Skip if gas cost > configured ratio of annual yield if (gasCost > annualYield * CONFIG.gasCostRatioCap) continue; const priority = idleBalance.balanceUSD > 50_000 ? "high" : idleBalance.balanceUSD > 10_000 ? "medium" : "low"; suggestions.push({ type: "deposit", priority, to: bestOnChain, amount: idleBalance.balance, amountUSD: idleBalance.balanceUSD, reason: `${idleBalance.balanceUSD.toLocaleString("en-US", { style: "currency", currency: "USD" })} ${idleBalance.asset} idle on ${YIELD_CHAIN_NAMES[idleBalance.chainId]} — earn ${bestOnChain.apy.toFixed(2)}% APY`, estimatedGasCostUSD: gasCost, }); } // ── Rebalance suggestions (low APY → high APY) ── for (const pos of positions) { const posUSD = Number(BigInt(pos.underlying)) / 10 ** pos.decimals; if (posUSD < CONFIG.minIdleThresholdUSD) continue; // Find better opportunity for same asset (any chain) const betterOptions = eligibleRates.filter( (r) => r.asset === pos.asset && r.apy > pos.apy + CONFIG.rebalanceThreshold && // Don't suggest moving to the same vault r.vaultAddress.toLowerCase() !== pos.vaultAddress.toLowerCase(), ); if (betterOptions.length === 0) continue; const best = betterOptions[0]; const withdrawGas = estimateGasCostUSD(pos.chainId, WITHDRAW_GAS); const depositGas = estimateGasCostUSD(best.chainId, DEPOSIT_GAS); const totalGas = withdrawGas + depositGas; const apyGain = best.apy - pos.apy; const annualGain = posUSD * (apyGain / 100); if (totalGas > annualGain * CONFIG.gasCostRatioCap) continue; suggestions.push({ type: "rebalance", priority: apyGain > 5 ? "high" : "medium", from: { protocol: pos.protocol, chainId: pos.chainId, vaultAddress: pos.vaultAddress, apy: pos.apy, }, to: best, amount: pos.underlying, amountUSD: posUSD, reason: `Move ${posUSD.toLocaleString("en-US", { style: "currency", currency: "USD" })} ${pos.asset} from ${pos.protocol} (${pos.apy.toFixed(2)}%) to ${best.protocol} (${best.apy.toFixed(2)}%) — +${apyGain.toFixed(2)}% APY`, estimatedGasCostUSD: totalGas, }); } // Sort: high priority first, then by amountUSD suggestions.sort((a, b) => { const prio = { high: 0, medium: 1, low: 2 }; const prioDiff = prio[a.priority] - prio[b.priority]; if (prioDiff !== 0) return prioDiff; return b.amountUSD - a.amountUSD; }); return { idleBalances: idle, positions, suggestions, totalIdleUSD, totalDepositedUSD, weightedAPY, }; }