244 lines
7.6 KiB
TypeScript
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,
|
|
};
|
|
}
|