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

244 lines
7.6 KiB
TypeScript

/**
* 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<SupportedYieldChain, number> = {
"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<bigint> {
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<IdleBalance[]> {
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<StrategyResult> {
// 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,
};
}