feat(rwallet): add Aave V3 + Morpho Blue auto-yield for idle treasury
Adds yield-generating capability for idle stablecoins (USDC/USDT/DAI) on Ethereum and Base via Aave V3 and Morpho Blue vaults, using the existing Safe multisig proposal flow for governance. New lib files: yield-protocols (constants/ABIs), yield-rates (DeFi Llama + Morpho GraphQL with 5min cache), yield-positions (on-chain queries), yield-tx-builder (MultiSend calldata for Safe proposals), yield-strategy (idle detection + allocation suggestions). 5 API routes, "Yield" view tab with rates table, position cards, and advisory strategy suggestions. Zero new dependencies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2264267ded
commit
3436393bfb
|
|
@ -80,9 +80,44 @@ const EXAMPLE_WALLETS = [
|
|||
{ name: "Vitalik.eth", address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", type: "EOA" },
|
||||
];
|
||||
|
||||
type ViewTab = "balances" | "timeline" | "flow" | "sankey";
|
||||
type ViewTab = "balances" | "timeline" | "flow" | "sankey" | "yield";
|
||||
type TopTab = "my-wallets" | "visualizer";
|
||||
|
||||
interface YieldRate {
|
||||
protocol: string;
|
||||
chainId: string;
|
||||
asset: string;
|
||||
assetAddress: string;
|
||||
vaultAddress: string;
|
||||
apy: number;
|
||||
apy7d?: number;
|
||||
tvl?: number;
|
||||
vaultName?: string;
|
||||
}
|
||||
|
||||
interface YieldPositionData {
|
||||
protocol: string;
|
||||
chainId: string;
|
||||
asset: string;
|
||||
vaultAddress: string;
|
||||
shares: string;
|
||||
underlying: string;
|
||||
decimals: number;
|
||||
apy: number;
|
||||
dailyEarnings: number;
|
||||
annualEarnings: number;
|
||||
}
|
||||
|
||||
interface YieldSuggestionData {
|
||||
type: "deposit" | "rebalance";
|
||||
priority: "high" | "medium" | "low";
|
||||
to: YieldRate;
|
||||
amount: string;
|
||||
amountUSD: number;
|
||||
reason: string;
|
||||
estimatedGasCostUSD: number;
|
||||
}
|
||||
|
||||
interface AllChainBalanceEntry {
|
||||
chainId: string;
|
||||
chainName: string;
|
||||
|
|
@ -122,6 +157,17 @@ class FolkWalletViewer extends HTMLElement {
|
|||
private myWalletBalances: Map<string, Array<{ chainId: string; chainName: string; balances: BalanceItem[] }>> = new Map();
|
||||
private myWalletsLoading = false;
|
||||
|
||||
// Yield state
|
||||
private yieldRates: YieldRate[] = [];
|
||||
private yieldPositions: YieldPositionData[] = [];
|
||||
private yieldSuggestions: YieldSuggestionData[] = [];
|
||||
private yieldLoading = false;
|
||||
private yieldTotalIdleUSD = 0;
|
||||
private yieldTotalDepositedUSD = 0;
|
||||
private yieldWeightedAPY = 0;
|
||||
private yieldDepositInProgress = false;
|
||||
private yieldError = "";
|
||||
|
||||
// Visualization state
|
||||
private activeView: ViewTab = "balances";
|
||||
private transfers: Map<string, any> | null = null;
|
||||
|
|
@ -156,7 +202,7 @@ class FolkWalletViewer extends HTMLElement {
|
|||
connectedCallback() {
|
||||
// Read initial-view attribute from server route
|
||||
const initialView = this.getAttribute("initial-view");
|
||||
if (initialView && ["balances", "timeline", "flow", "sankey"].includes(initialView)) {
|
||||
if (initialView && ["balances", "timeline", "flow", "sankey", "yield"].includes(initialView)) {
|
||||
this.activeView = initialView as ViewTab;
|
||||
}
|
||||
|
||||
|
|
@ -261,6 +307,106 @@ class FolkWalletViewer extends HTMLElement {
|
|||
this.render();
|
||||
}
|
||||
|
||||
// ── Yield data loading ──
|
||||
|
||||
private async loadYieldData() {
|
||||
this.yieldLoading = true;
|
||||
this.yieldError = "";
|
||||
this.render();
|
||||
|
||||
const base = this.getApiBase();
|
||||
|
||||
try {
|
||||
// Always fetch rates (public)
|
||||
const ratesRes = await fetch(`${base}/api/yield/rates`);
|
||||
if (ratesRes.ok) {
|
||||
const data = await ratesRes.json();
|
||||
this.yieldRates = data.rates || [];
|
||||
}
|
||||
|
||||
// Fetch positions if we have an address
|
||||
if (this.address) {
|
||||
const posRes = await fetch(`${base}/api/yield/${this.address}/positions`);
|
||||
if (posRes.ok) {
|
||||
const data = await posRes.json();
|
||||
this.yieldPositions = data.positions || [];
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch strategy if authenticated
|
||||
const token = this.getAuthToken();
|
||||
if (token && this.address) {
|
||||
const stratRes = await fetch(`${base}/api/yield/${this.address}/strategy`, {
|
||||
headers: { "Authorization": `Bearer ${token}` },
|
||||
});
|
||||
if (stratRes.ok) {
|
||||
const data = await stratRes.json();
|
||||
const s = data.strategy || {};
|
||||
this.yieldSuggestions = s.suggestions || [];
|
||||
this.yieldTotalIdleUSD = s.totalIdleUSD || 0;
|
||||
this.yieldTotalDepositedUSD = s.totalDepositedUSD || 0;
|
||||
this.yieldWeightedAPY = s.weightedAPY || 0;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.yieldError = "Failed to load yield data";
|
||||
console.warn("yield load error", err);
|
||||
}
|
||||
|
||||
this.yieldLoading = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async handleYieldDeposit(suggestion: YieldSuggestionData) {
|
||||
const token = this.getAuthToken();
|
||||
if (!token) {
|
||||
this.yieldError = "Authentication required for deposits";
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
this.yieldDepositInProgress = true;
|
||||
this.yieldError = "";
|
||||
this.render();
|
||||
|
||||
const base = this.getApiBase();
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${base}/api/yield/${suggestion.to.chainId}/${this.address}/build-deposit`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
protocol: suggestion.to.protocol,
|
||||
asset: suggestion.to.asset,
|
||||
amount: suggestion.amount,
|
||||
vaultAddress: suggestion.to.vaultAddress,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
this.yieldError = err.error || "Failed to build deposit transaction";
|
||||
} else {
|
||||
const data = await res.json();
|
||||
// Transaction built — show info to user (would connect to Safe propose flow)
|
||||
this.yieldError = "";
|
||||
alert(
|
||||
`Transaction ready for Safe proposal:\n\n${data.transaction.description}\n\nSteps:\n${data.transaction.steps.map((s: string, i: number) => `${i + 1}. ${s}`).join("\n")}\n\nSubmit this via your Safe multisig to execute.`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
this.yieldError = "Network error building deposit transaction";
|
||||
}
|
||||
|
||||
this.yieldDepositInProgress = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async loadMyWalletBalances() {
|
||||
const addresses: Array<{ address: string; type: "eoa" | "safe" }> = [];
|
||||
|
||||
|
|
@ -699,13 +845,15 @@ class FolkWalletViewer extends HTMLElement {
|
|||
if (this.activeView === view) return;
|
||||
this.activeView = view;
|
||||
|
||||
if (view !== "balances" && !this.transfers && !this.isDemo) {
|
||||
if (view === "yield" && this.yieldRates.length === 0) {
|
||||
this.loadYieldData();
|
||||
} else if (view !== "balances" && view !== "yield" && !this.transfers && !this.isDemo) {
|
||||
this.loadTransfers();
|
||||
}
|
||||
|
||||
this.render();
|
||||
|
||||
if (view !== "balances") {
|
||||
if (view !== "balances" && view !== "yield") {
|
||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
||||
}
|
||||
}
|
||||
|
|
@ -1273,6 +1421,69 @@ class FolkWalletViewer extends HTMLElement {
|
|||
display: block; max-width: 720px; margin: 20px auto 0; text-align: center;
|
||||
}
|
||||
|
||||
/* ── Yield tab ── */
|
||||
.yield-summary {
|
||||
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle);
|
||||
border-radius: 12px; padding: 16px 20px; margin-bottom: 20px;
|
||||
}
|
||||
.yield-summary-row {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px;
|
||||
}
|
||||
.yield-stat { display: flex; flex-direction: column; align-items: center; }
|
||||
.yield-stat-label { font-size: 11px; color: var(--rs-text-secondary); text-transform: uppercase; margin-bottom: 4px; }
|
||||
.yield-stat-value { font-size: 20px; font-weight: 700; color: var(--rs-accent); font-family: monospace; }
|
||||
.yield-section { margin-bottom: 24px; }
|
||||
.yield-section-title {
|
||||
font-size: 14px; font-weight: 600; color: var(--rs-text-primary);
|
||||
margin: 0 0 12px; padding-bottom: 6px; border-bottom: 1px solid var(--rs-border-subtle);
|
||||
}
|
||||
.yield-positions { display: flex; flex-direction: column; gap: 10px; }
|
||||
.yield-position-card {
|
||||
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle);
|
||||
border-radius: 10px; padding: 14px;
|
||||
}
|
||||
.yield-pos-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
.yield-pos-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||
.yield-pos-label { display: block; font-size: 10px; color: var(--rs-text-muted); text-transform: uppercase; }
|
||||
.yield-pos-value { font-size: 14px; font-weight: 600; color: var(--rs-text-primary); font-family: monospace; }
|
||||
.yield-protocol-badge {
|
||||
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 4px; text-transform: uppercase;
|
||||
}
|
||||
.yield-protocol-badge.aave-v3 { background: rgba(180,128,255,0.12); color: #b480ff; }
|
||||
.yield-protocol-badge.morpho-blue { background: rgba(0,148,255,0.12); color: #0094ff; }
|
||||
.yield-chain-badge {
|
||||
font-size: 10px; padding: 2px 6px; border-radius: 4px;
|
||||
background: var(--rs-bg-hover); color: var(--rs-text-secondary);
|
||||
}
|
||||
.yield-suggestion {
|
||||
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle);
|
||||
border-radius: 10px; padding: 14px; margin-bottom: 8px;
|
||||
}
|
||||
.yield-prio-badge {
|
||||
font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 4px;
|
||||
text-transform: uppercase; margin-right: 6px;
|
||||
}
|
||||
.prio-high { background: rgba(239,83,80,0.15); color: #ef5350; }
|
||||
.prio-med { background: rgba(255,167,38,0.15); color: #ffa726; }
|
||||
.prio-low { background: rgba(102,187,106,0.15); color: #66bb6a; }
|
||||
.yield-suggestion-type {
|
||||
font-size: 12px; font-weight: 600; color: var(--rs-text-primary); text-transform: uppercase;
|
||||
}
|
||||
.yield-suggestion-reason {
|
||||
font-size: 13px; color: var(--rs-text-secondary); margin: 8px 0; line-height: 1.4;
|
||||
}
|
||||
.yield-suggestion-footer {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.yield-suggestion-gas { font-size: 11px; color: var(--rs-text-muted); }
|
||||
.yield-action-btn {
|
||||
padding: 6px 16px; border-radius: 6px; border: 1px solid var(--rs-accent);
|
||||
background: transparent; color: var(--rs-accent); cursor: pointer; font-size: 12px;
|
||||
font-weight: 600; transition: all 0.2s;
|
||||
}
|
||||
.yield-action-btn:hover { background: rgba(20,184,166,0.1); }
|
||||
.yield-action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-title { font-size: 22px; }
|
||||
.balance-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
|
|
@ -1682,13 +1893,14 @@ class FolkWalletViewer extends HTMLElement {
|
|||
if (!this.hasData()) return "";
|
||||
const tabs: { id: ViewTab; label: string }[] = [
|
||||
{ id: "balances", label: "Balances" },
|
||||
{ id: "yield", label: "Yield" },
|
||||
{ id: "timeline", label: "Timeline" },
|
||||
{ id: "flow", label: "Flow Map" },
|
||||
{ id: "sankey", label: "Sankey" },
|
||||
];
|
||||
// Only show viz tabs for Safe wallets (or demo)
|
||||
const showViz = this.walletType === "safe" || this.isDemo;
|
||||
const visibleTabs = showViz ? tabs : [tabs[0]];
|
||||
const visibleTabs = showViz ? tabs : [tabs[0], tabs[1]];
|
||||
|
||||
return `
|
||||
<div class="view-tabs">
|
||||
|
|
@ -1698,6 +1910,133 @@ class FolkWalletViewer extends HTMLElement {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
private renderYieldTab(): string {
|
||||
if (this.yieldLoading) {
|
||||
return '<div class="loading"><span class="spinner"></span> Loading yield data...</div>';
|
||||
}
|
||||
if (this.yieldError) {
|
||||
return `<div class="error">${this.esc(this.yieldError)}</div>`;
|
||||
}
|
||||
|
||||
const chainNames: Record<string, string> = { "1": "Ethereum", "8453": "Base" };
|
||||
let html = "";
|
||||
|
||||
// ── Strategy summary banner ──
|
||||
if (this.yieldSuggestions.length > 0 || this.yieldTotalDepositedUSD > 0) {
|
||||
html += `<div class="yield-summary">
|
||||
<div class="yield-summary-row">
|
||||
<div class="yield-stat">
|
||||
<span class="yield-stat-label">Idle Stablecoins</span>
|
||||
<span class="yield-stat-value">${this.formatUSD(String(this.yieldTotalIdleUSD))}</span>
|
||||
</div>
|
||||
<div class="yield-stat">
|
||||
<span class="yield-stat-label">Earning Yield</span>
|
||||
<span class="yield-stat-value">${this.formatUSD(String(this.yieldTotalDepositedUSD))}</span>
|
||||
</div>
|
||||
<div class="yield-stat">
|
||||
<span class="yield-stat-label">Weighted APY</span>
|
||||
<span class="yield-stat-value" style="color:var(--rs-success)">${this.yieldWeightedAPY.toFixed(2)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Positions ──
|
||||
if (this.yieldPositions.length > 0) {
|
||||
html += `<div class="yield-section">
|
||||
<h3 class="yield-section-title">Active Positions</h3>
|
||||
<div class="yield-positions">`;
|
||||
for (const pos of this.yieldPositions) {
|
||||
const value = Number(BigInt(pos.underlying)) / 10 ** pos.decimals;
|
||||
html += `
|
||||
<div class="yield-position-card">
|
||||
<div class="yield-pos-header">
|
||||
<span class="yield-protocol-badge ${pos.protocol}">${pos.protocol === "aave-v3" ? "Aave V3" : "Morpho"}</span>
|
||||
<span class="yield-chain-badge">${chainNames[pos.chainId] || pos.chainId}</span>
|
||||
<span style="margin-left:auto;font-weight:600;color:var(--rs-text-primary)">${pos.asset}</span>
|
||||
</div>
|
||||
<div class="yield-pos-stats">
|
||||
<div>
|
||||
<span class="yield-pos-label">Deposited</span>
|
||||
<span class="yield-pos-value">${value.toLocaleString("en-US", { style: "currency", currency: "USD" })}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="yield-pos-label">APY</span>
|
||||
<span class="yield-pos-value" style="color:var(--rs-success)">${pos.apy.toFixed(2)}%</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="yield-pos-label">Daily</span>
|
||||
<span class="yield-pos-value">${pos.dailyEarnings.toFixed(2)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="yield-pos-label">Annual</span>
|
||||
<span class="yield-pos-value">${pos.annualEarnings.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
// ── Suggestions ──
|
||||
if (this.yieldSuggestions.length > 0) {
|
||||
html += `<div class="yield-section">
|
||||
<h3 class="yield-section-title">Suggestions</h3>`;
|
||||
for (let i = 0; i < this.yieldSuggestions.length; i++) {
|
||||
const s = this.yieldSuggestions[i];
|
||||
const prioClass = s.priority === "high" ? "prio-high" : s.priority === "medium" ? "prio-med" : "prio-low";
|
||||
html += `
|
||||
<div class="yield-suggestion">
|
||||
<span class="yield-prio-badge ${prioClass}">${s.priority}</span>
|
||||
<span class="yield-suggestion-type">${s.type === "deposit" ? "Deposit" : "Rebalance"}</span>
|
||||
<p class="yield-suggestion-reason">${this.esc(s.reason)}</p>
|
||||
<div class="yield-suggestion-footer">
|
||||
<span class="yield-suggestion-gas">Est. gas: ${s.estimatedGasCostUSD.toFixed(2)}</span>
|
||||
${this.isAuthenticated && this.walletType === "safe" ? `<button class="yield-action-btn" data-yield-deposit="${i}" ${this.yieldDepositInProgress ? "disabled" : ""}>
|
||||
${this.yieldDepositInProgress ? "Building..." : "Execute"}
|
||||
</button>` : ""}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// ── Rates table ──
|
||||
if (this.yieldRates.length > 0) {
|
||||
html += `<div class="yield-section">
|
||||
<h3 class="yield-section-title">Available Rates</h3>
|
||||
<table class="balance-table">
|
||||
<thead><tr>
|
||||
<th>Protocol</th>
|
||||
<th>Chain</th>
|
||||
<th>Asset</th>
|
||||
<th class="amount-cell">APY</th>
|
||||
<th class="amount-cell">7d Avg</th>
|
||||
<th class="amount-cell">TVL</th>
|
||||
</tr></thead>
|
||||
<tbody>`;
|
||||
const sorted = [...this.yieldRates].sort((a, b) => b.apy - a.apy);
|
||||
for (const r of sorted) {
|
||||
const protocolLabel = r.protocol === "aave-v3" ? "Aave V3" : (r.vaultName || "Morpho");
|
||||
html += `<tr>
|
||||
<td><span class="yield-protocol-badge ${r.protocol}">${protocolLabel}</span></td>
|
||||
<td>${chainNames[r.chainId] || r.chainId}</td>
|
||||
<td><span class="token-symbol">${this.esc(r.asset)}</span></td>
|
||||
<td class="amount-cell" style="color:var(--rs-success);font-weight:600">${r.apy.toFixed(2)}%</td>
|
||||
<td class="amount-cell">${r.apy7d ? r.apy7d.toFixed(2) + "%" : "-"}</td>
|
||||
<td class="amount-cell">${r.tvl ? "$" + (r.tvl / 1e6).toFixed(1) + "M" : "-"}</td>
|
||||
</tr>`;
|
||||
}
|
||||
html += `</tbody></table></div>`;
|
||||
}
|
||||
|
||||
if (!html) {
|
||||
html = '<div class="empty">No yield data available. Yield is supported on Ethereum and Base for USDC, USDT, and DAI.</div>';
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private renderBalanceTable(): string {
|
||||
const unified = this.getUnifiedBalances();
|
||||
if (unified.length === 0) return '<div class="empty">No token balances found.</div>';
|
||||
|
|
@ -1795,6 +2134,8 @@ class FolkWalletViewer extends HTMLElement {
|
|||
|
||||
${this.activeView === "balances"
|
||||
? this.renderBalanceTable()
|
||||
: this.activeView === "yield"
|
||||
? this.renderYieldTab()
|
||||
: `<div class="viz-container" id="viz-container">
|
||||
${this.transfersLoading ? '<div class="loading"><span class="spinner"></span> Loading transfer data...</div>' : ""}
|
||||
</div>`
|
||||
|
|
@ -1881,8 +2222,18 @@ class FolkWalletViewer extends HTMLElement {
|
|||
|
||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
|
||||
// Yield deposit buttons
|
||||
this.shadow.querySelectorAll("[data-yield-deposit]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const idx = parseInt((btn as HTMLElement).dataset.yieldDeposit!, 10);
|
||||
if (this.yieldSuggestions[idx]) {
|
||||
this.handleYieldDeposit(this.yieldSuggestions[idx]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Draw visualization if active
|
||||
if (this.activeView !== "balances" && this.hasData()) {
|
||||
if (this.activeView !== "balances" && this.activeView !== "yield" && this.hasData()) {
|
||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
/**
|
||||
* Yield position tracking — queries on-chain aToken and vault share balances
|
||||
* for a given address across Aave V3 and Morpho Blue vaults.
|
||||
*/
|
||||
|
||||
import type { YieldPosition, SupportedYieldChain, StablecoinSymbol } from "./yield-protocols";
|
||||
import {
|
||||
AAVE_ATOKENS, MORPHO_VAULTS,
|
||||
STABLECOIN_ADDRESSES, STABLECOIN_DECIMALS,
|
||||
encodeBalanceOf, encodeConvertToAssets,
|
||||
YIELD_CHAIN_NAMES,
|
||||
} from "./yield-protocols";
|
||||
import { getRpcUrl } from "../mod";
|
||||
import { getYieldRates } from "./yield-rates";
|
||||
|
||||
async function rpcCall(rpcUrl: string, to: string, data: string): Promise<string | null> {
|
||||
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, data }, "latest"],
|
||||
}),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const result = (await res.json()).result;
|
||||
if (!result || result === "0x" || result === "0x0") return null;
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface PositionQuery {
|
||||
protocol: "aave-v3" | "morpho-blue";
|
||||
chainId: SupportedYieldChain;
|
||||
asset: StablecoinSymbol;
|
||||
assetAddress: string;
|
||||
vaultAddress: string;
|
||||
vaultName?: string;
|
||||
}
|
||||
|
||||
function buildPositionQueries(): PositionQuery[] {
|
||||
const queries: PositionQuery[] = [];
|
||||
const chains: SupportedYieldChain[] = ["1", "8453"];
|
||||
|
||||
for (const chainId of chains) {
|
||||
// Aave aTokens
|
||||
const aTokens = AAVE_ATOKENS[chainId];
|
||||
if (aTokens) {
|
||||
for (const [asset, aToken] of Object.entries(aTokens)) {
|
||||
const assetAddr = STABLECOIN_ADDRESSES[chainId]?.[asset as StablecoinSymbol];
|
||||
if (assetAddr && assetAddr !== "0x0000000000000000000000000000000000000000") {
|
||||
queries.push({
|
||||
protocol: "aave-v3",
|
||||
chainId,
|
||||
asset: asset as StablecoinSymbol,
|
||||
assetAddress: assetAddr,
|
||||
vaultAddress: aToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Morpho vaults
|
||||
for (const vault of MORPHO_VAULTS[chainId] || []) {
|
||||
const assetAddr = STABLECOIN_ADDRESSES[chainId]?.[vault.asset];
|
||||
if (assetAddr && assetAddr !== "0x0000000000000000000000000000000000000000") {
|
||||
queries.push({
|
||||
protocol: "morpho-blue",
|
||||
chainId,
|
||||
asset: vault.asset,
|
||||
assetAddress: assetAddr,
|
||||
vaultAddress: vault.address,
|
||||
vaultName: vault.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
export async function getYieldPositions(address: string): Promise<YieldPosition[]> {
|
||||
const queries = buildPositionQueries();
|
||||
const positions: YieldPosition[] = [];
|
||||
|
||||
// Get current rates for APY enrichment
|
||||
const rates = await getYieldRates();
|
||||
const rateMap = new Map(rates.map((r) => [`${r.protocol}:${r.chainId}:${r.vaultAddress.toLowerCase()}`, r]));
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
queries.map(async (q) => {
|
||||
const rpcUrl = getRpcUrl(q.chainId);
|
||||
if (!rpcUrl) return null;
|
||||
|
||||
const balanceHex = await rpcCall(rpcUrl, q.vaultAddress, encodeBalanceOf(address));
|
||||
if (!balanceHex) return null;
|
||||
|
||||
const shares = BigInt(balanceHex);
|
||||
if (shares === 0n) return null;
|
||||
|
||||
const decimals = STABLECOIN_DECIMALS[q.asset];
|
||||
let underlying = shares; // For Aave, aToken balance = underlying (rebasing)
|
||||
|
||||
if (q.protocol === "morpho-blue") {
|
||||
// Convert shares to underlying via convertToAssets
|
||||
const assetsHex = await rpcCall(rpcUrl, q.vaultAddress, encodeConvertToAssets(shares));
|
||||
if (assetsHex) {
|
||||
underlying = BigInt(assetsHex);
|
||||
}
|
||||
}
|
||||
|
||||
// Look up APY
|
||||
const rateKey = `${q.protocol}:${q.chainId}:${q.vaultAddress.toLowerCase()}`;
|
||||
const rate = rateMap.get(rateKey);
|
||||
const apy = rate?.apy || 0;
|
||||
|
||||
const underlyingUSD = Number(underlying) / 10 ** decimals; // stablecoins ≈ $1
|
||||
const annualEarnings = underlyingUSD * (apy / 100);
|
||||
const dailyEarnings = annualEarnings / 365;
|
||||
|
||||
return {
|
||||
protocol: q.protocol,
|
||||
chainId: q.chainId,
|
||||
asset: q.asset,
|
||||
assetAddress: q.assetAddress,
|
||||
vaultAddress: q.vaultAddress,
|
||||
shares: shares.toString(),
|
||||
underlying: underlying.toString(),
|
||||
decimals,
|
||||
apy,
|
||||
dailyEarnings,
|
||||
annualEarnings,
|
||||
} satisfies YieldPosition;
|
||||
}),
|
||||
);
|
||||
|
||||
for (const r of results) {
|
||||
if (r.status === "fulfilled" && r.value) {
|
||||
positions.push(r.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by underlying value descending
|
||||
positions.sort((a, b) => {
|
||||
const aVal = Number(BigInt(b.underlying)) / 10 ** b.decimals;
|
||||
const bVal = Number(BigInt(a.underlying)) / 10 ** a.decimals;
|
||||
return aVal - bVal;
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* Yield protocol constants — contract addresses, function selectors, and types
|
||||
* for Aave V3 and Morpho Blue vault integrations.
|
||||
*/
|
||||
|
||||
// ── Supported chains for yield ──
|
||||
export type SupportedYieldChain = "1" | "8453";
|
||||
export type YieldProtocol = "aave-v3" | "morpho-blue";
|
||||
export type StablecoinSymbol = "USDC" | "USDT" | "DAI";
|
||||
|
||||
export interface YieldOpportunity {
|
||||
protocol: YieldProtocol;
|
||||
chainId: SupportedYieldChain;
|
||||
asset: StablecoinSymbol;
|
||||
assetAddress: string;
|
||||
vaultAddress: string; // aToken for Aave, vault for Morpho
|
||||
apy: number;
|
||||
apy7d?: number;
|
||||
tvl?: number;
|
||||
poolId?: string; // DeFi Llama pool ID
|
||||
vaultName?: string;
|
||||
}
|
||||
|
||||
export interface YieldPosition {
|
||||
protocol: YieldProtocol;
|
||||
chainId: SupportedYieldChain;
|
||||
asset: StablecoinSymbol;
|
||||
assetAddress: string;
|
||||
vaultAddress: string;
|
||||
shares: string; // raw shares/aToken balance
|
||||
underlying: string; // underlying asset value
|
||||
decimals: number;
|
||||
apy: number;
|
||||
dailyEarnings: number;
|
||||
annualEarnings: number;
|
||||
}
|
||||
|
||||
export interface YieldSuggestion {
|
||||
type: "deposit" | "rebalance";
|
||||
priority: "high" | "medium" | "low";
|
||||
from?: { protocol: YieldProtocol; chainId: SupportedYieldChain; vaultAddress: string; apy: number };
|
||||
to: YieldOpportunity;
|
||||
amount: string;
|
||||
amountUSD: number;
|
||||
reason: string;
|
||||
estimatedGasCostUSD: number;
|
||||
}
|
||||
|
||||
// ── Stablecoin addresses per chain ──
|
||||
export const STABLECOIN_ADDRESSES: Record<SupportedYieldChain, Record<StablecoinSymbol, string>> = {
|
||||
"1": {
|
||||
USDC: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
||||
USDT: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
||||
DAI: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
|
||||
},
|
||||
"8453": {
|
||||
USDC: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
||||
USDT: "0x0000000000000000000000000000000000000000", // not on Base
|
||||
DAI: "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb",
|
||||
},
|
||||
};
|
||||
|
||||
export const STABLECOIN_DECIMALS: Record<StablecoinSymbol, number> = {
|
||||
USDC: 6,
|
||||
USDT: 6,
|
||||
DAI: 18,
|
||||
};
|
||||
|
||||
// ── Aave V3 ──
|
||||
export const AAVE_V3_POOL: Record<SupportedYieldChain, string> = {
|
||||
"1": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2",
|
||||
"8453": "0xA238Dd80C259a72e81d7e4664a9801593F98d1c5",
|
||||
};
|
||||
|
||||
// aToken addresses (receipt tokens for deposits)
|
||||
export const AAVE_ATOKENS: Record<SupportedYieldChain, Partial<Record<StablecoinSymbol, string>>> = {
|
||||
"1": {
|
||||
USDC: "0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c",
|
||||
USDT: "0x23878914EFE38d27C4D67Ab83ed1b93A74D4086a",
|
||||
DAI: "0x018008bfb33d285247A21d44E50697654f754e63",
|
||||
},
|
||||
"8453": {
|
||||
USDC: "0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB",
|
||||
DAI: "0x0a1d576f3eFeF75b330424287a95A366e8281D54",
|
||||
},
|
||||
};
|
||||
|
||||
// ── Morpho Blue Vaults ──
|
||||
export interface MorphoVaultInfo {
|
||||
address: string;
|
||||
name: string;
|
||||
asset: StablecoinSymbol;
|
||||
}
|
||||
|
||||
export const MORPHO_VAULTS: Record<SupportedYieldChain, MorphoVaultInfo[]> = {
|
||||
"1": [
|
||||
{ address: "0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB", name: "Steakhouse USDC", asset: "USDC" },
|
||||
{ address: "0x2371e134e3455e0593363cBF89d3b6cf53740618", name: "Steakhouse USDT", asset: "USDT" },
|
||||
],
|
||||
"8453": [
|
||||
{ address: "0xc1256Ae5FF1cf2719D4937adb3bbCCab2E00A2Ca", name: "Moonwell Flagship USDC", asset: "USDC" },
|
||||
],
|
||||
};
|
||||
|
||||
// ── MultiSend (for batched approve+deposit) ──
|
||||
export const MULTISEND_CALL_ONLY: Record<SupportedYieldChain, string> = {
|
||||
"1": "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D",
|
||||
"8453": "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D",
|
||||
};
|
||||
|
||||
// ── Function selectors ──
|
||||
export const SELECTORS = {
|
||||
// ERC-20
|
||||
approve: "0x095ea7b3", // approve(address,uint256)
|
||||
balanceOf: "0x70a08231", // balanceOf(address)
|
||||
// Aave V3 Pool
|
||||
supply: "0x617ba037", // supply(address,uint256,address,uint16)
|
||||
withdraw: "0x69328dec", // withdraw(address,uint256,address)
|
||||
getReserveData: "0x35ea6a75", // getReserveData(address)
|
||||
// ERC-4626 (Morpho vaults)
|
||||
deposit: "0x6e553f65", // deposit(uint256,address)
|
||||
redeem: "0xba087652", // redeem(uint256,address,address)
|
||||
convertToAssets: "0x07a2d13a", // convertToAssets(uint256)
|
||||
maxDeposit: "0x402d267d", // maxDeposit(address)
|
||||
// MultiSend
|
||||
multiSend: "0x8d80ff0a", // multiSend(bytes)
|
||||
} as const;
|
||||
|
||||
// ── ABI encoding helpers ──
|
||||
export function padAddress(addr: string): string {
|
||||
return addr.slice(2).toLowerCase().padStart(64, "0");
|
||||
}
|
||||
|
||||
export function padUint256(value: bigint): string {
|
||||
return value.toString(16).padStart(64, "0");
|
||||
}
|
||||
|
||||
export function encodeApprove(spender: string, amount: bigint): string {
|
||||
return `${SELECTORS.approve}${padAddress(spender)}${padUint256(amount)}`;
|
||||
}
|
||||
|
||||
export function encodeAaveSupply(asset: string, amount: bigint, onBehalfOf: string): string {
|
||||
return `${SELECTORS.supply}${padAddress(asset)}${padUint256(amount)}${padAddress(onBehalfOf)}${padUint256(0n)}`;
|
||||
}
|
||||
|
||||
export function encodeAaveWithdraw(asset: string, amount: bigint, to: string): string {
|
||||
return `${SELECTORS.withdraw}${padAddress(asset)}${padUint256(amount)}${padAddress(to)}`;
|
||||
}
|
||||
|
||||
export function encodeMorphoDeposit(amount: bigint, receiver: string): string {
|
||||
return `${SELECTORS.deposit}${padUint256(amount)}${padAddress(receiver)}`;
|
||||
}
|
||||
|
||||
export function encodeMorphoRedeem(shares: bigint, receiver: string, owner: string): string {
|
||||
return `${SELECTORS.redeem}${padUint256(shares)}${padAddress(receiver)}${padAddress(owner)}`;
|
||||
}
|
||||
|
||||
export function encodeBalanceOf(address: string): string {
|
||||
return `${SELECTORS.balanceOf}${padAddress(address)}`;
|
||||
}
|
||||
|
||||
export function encodeConvertToAssets(shares: bigint): string {
|
||||
return `${SELECTORS.convertToAssets}${padUint256(shares)}`;
|
||||
}
|
||||
|
||||
export function encodeMaxDeposit(receiver: string): string {
|
||||
return `${SELECTORS.maxDeposit}${padAddress(receiver)}`;
|
||||
}
|
||||
|
||||
// ── Chain display names ──
|
||||
export const YIELD_CHAIN_NAMES: Record<SupportedYieldChain, string> = {
|
||||
"1": "Ethereum",
|
||||
"8453": "Base",
|
||||
};
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
/**
|
||||
* 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;
|
||||
apyMean7d?: 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,
|
||||
apy7d: pool.apyMean7d,
|
||||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
/**
|
||||
* Safe transaction builder for yield operations — builds calldata for
|
||||
* approve+deposit (via MultiSend) and single-tx withdrawals.
|
||||
*/
|
||||
|
||||
import type { SupportedYieldChain, YieldProtocol, StablecoinSymbol } from "./yield-protocols";
|
||||
import {
|
||||
AAVE_V3_POOL, MORPHO_VAULTS, MULTISEND_CALL_ONLY,
|
||||
STABLECOIN_ADDRESSES, STABLECOIN_DECIMALS,
|
||||
encodeApprove, encodeAaveSupply, encodeAaveWithdraw,
|
||||
encodeMorphoDeposit, encodeMorphoRedeem,
|
||||
encodeMaxDeposit, encodeBalanceOf,
|
||||
SELECTORS, padUint256,
|
||||
AAVE_ATOKENS,
|
||||
} from "./yield-protocols";
|
||||
import { getRpcUrl } from "../mod";
|
||||
|
||||
export interface SafeTxData {
|
||||
to: string;
|
||||
value: string;
|
||||
data: string;
|
||||
operation: number; // 0 = Call, 1 = DelegateCall
|
||||
description: string;
|
||||
steps: string[];
|
||||
}
|
||||
|
||||
// ── MultiSend encoding ──
|
||||
|
||||
interface MultiSendTx {
|
||||
operation: number; // 0 = Call
|
||||
to: string;
|
||||
value: bigint;
|
||||
data: string; // hex with 0x prefix
|
||||
}
|
||||
|
||||
function encodeMultiSend(txs: MultiSendTx[]): string {
|
||||
let packed = "";
|
||||
for (const tx of txs) {
|
||||
const dataBytes = tx.data.startsWith("0x") ? tx.data.slice(2) : tx.data;
|
||||
const dataLength = dataBytes.length / 2;
|
||||
packed +=
|
||||
padUint256(BigInt(tx.operation)).slice(62, 64) + // operation: 1 byte (last byte of uint8)
|
||||
tx.to.slice(2).toLowerCase().padStart(40, "0") + // to: 20 bytes
|
||||
padUint256(tx.value) + // value: 32 bytes
|
||||
padUint256(BigInt(dataLength)) + // dataLength: 32 bytes
|
||||
dataBytes; // data: variable
|
||||
}
|
||||
|
||||
// Wrap in multiSend(bytes) — ABI encode: selector + offset(32) + length(32) + data + padding
|
||||
const packedBytes = packed;
|
||||
const packedLength = packedBytes.length / 2;
|
||||
const offset = padUint256(32n); // bytes offset
|
||||
const length = padUint256(BigInt(packedLength));
|
||||
// Pad to 32-byte boundary
|
||||
const padLen = (32 - (packedLength % 32)) % 32;
|
||||
const padding = "0".repeat(padLen * 2);
|
||||
|
||||
return `${SELECTORS.multiSend}${offset}${length}${packedBytes}${padding}`;
|
||||
}
|
||||
|
||||
// ── RPC helper ──
|
||||
|
||||
async function rpcCall(rpcUrl: string, to: string, data: string): Promise<string | null> {
|
||||
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, data }, "latest"],
|
||||
}),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const result = (await res.json()).result;
|
||||
if (!result || result === "0x") return null;
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Deposit builder ──
|
||||
|
||||
export async function buildDepositTransaction(
|
||||
chainId: SupportedYieldChain,
|
||||
safeAddress: string,
|
||||
protocol: YieldProtocol,
|
||||
asset: StablecoinSymbol,
|
||||
amount: bigint,
|
||||
vaultAddress?: string, // required for Morpho (which vault)
|
||||
): Promise<SafeTxData> {
|
||||
const assetAddress = STABLECOIN_ADDRESSES[chainId]?.[asset];
|
||||
if (!assetAddress || assetAddress === "0x0000000000000000000000000000000000000000") {
|
||||
throw new Error(`${asset} not available on chain ${chainId}`);
|
||||
}
|
||||
|
||||
const decimals = STABLECOIN_DECIMALS[asset];
|
||||
const amountStr = `${Number(amount) / 10 ** decimals} ${asset}`;
|
||||
|
||||
if (protocol === "aave-v3") {
|
||||
const pool = AAVE_V3_POOL[chainId];
|
||||
const approveTx: MultiSendTx = {
|
||||
operation: 0,
|
||||
to: assetAddress,
|
||||
value: 0n,
|
||||
data: encodeApprove(pool, amount),
|
||||
};
|
||||
const supplyTx: MultiSendTx = {
|
||||
operation: 0,
|
||||
to: pool,
|
||||
value: 0n,
|
||||
data: encodeAaveSupply(assetAddress, amount, safeAddress),
|
||||
};
|
||||
|
||||
return {
|
||||
to: MULTISEND_CALL_ONLY[chainId],
|
||||
value: "0",
|
||||
data: encodeMultiSend([approveTx, supplyTx]),
|
||||
operation: 1, // DelegateCall for MultiSend
|
||||
description: `Deposit ${amountStr} to Aave V3`,
|
||||
steps: [
|
||||
`Approve Aave V3 Pool to spend ${amountStr}`,
|
||||
`Supply ${amountStr} to Aave V3 Pool`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Morpho Blue vault
|
||||
const vault = vaultAddress || MORPHO_VAULTS[chainId]?.find((v) => v.asset === asset)?.address;
|
||||
if (!vault) throw new Error(`No Morpho vault for ${asset} on chain ${chainId}`);
|
||||
|
||||
// Check vault capacity
|
||||
const rpcUrl = getRpcUrl(chainId);
|
||||
if (rpcUrl) {
|
||||
const maxHex = await rpcCall(rpcUrl, vault, encodeMaxDeposit(safeAddress));
|
||||
if (maxHex) {
|
||||
const maxDeposit = BigInt(maxHex);
|
||||
if (maxDeposit < amount) {
|
||||
throw new Error(`Morpho vault capacity exceeded: max ${Number(maxDeposit) / 10 ** decimals} ${asset}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const approveTx: MultiSendTx = {
|
||||
operation: 0,
|
||||
to: assetAddress,
|
||||
value: 0n,
|
||||
data: encodeApprove(vault, amount),
|
||||
};
|
||||
const depositTx: MultiSendTx = {
|
||||
operation: 0,
|
||||
to: vault,
|
||||
value: 0n,
|
||||
data: encodeMorphoDeposit(amount, safeAddress),
|
||||
};
|
||||
|
||||
const vaultName = MORPHO_VAULTS[chainId]?.find((v) => v.address.toLowerCase() === vault.toLowerCase())?.name || "Morpho Vault";
|
||||
|
||||
return {
|
||||
to: MULTISEND_CALL_ONLY[chainId],
|
||||
value: "0",
|
||||
data: encodeMultiSend([approveTx, depositTx]),
|
||||
operation: 1, // DelegateCall for MultiSend
|
||||
description: `Deposit ${amountStr} to ${vaultName}`,
|
||||
steps: [
|
||||
`Approve ${vaultName} to spend ${amountStr}`,
|
||||
`Deposit ${amountStr} into ${vaultName}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Withdraw builder ──
|
||||
|
||||
export async function buildWithdrawTransaction(
|
||||
chainId: SupportedYieldChain,
|
||||
safeAddress: string,
|
||||
protocol: YieldProtocol,
|
||||
asset: StablecoinSymbol,
|
||||
amount: bigint | "max",
|
||||
vaultAddress?: string,
|
||||
): Promise<SafeTxData> {
|
||||
const assetAddress = STABLECOIN_ADDRESSES[chainId]?.[asset];
|
||||
if (!assetAddress || assetAddress === "0x0000000000000000000000000000000000000000") {
|
||||
throw new Error(`${asset} not available on chain ${chainId}`);
|
||||
}
|
||||
|
||||
const decimals = STABLECOIN_DECIMALS[asset];
|
||||
|
||||
if (protocol === "aave-v3") {
|
||||
// For "max", use type(uint256).max which tells Aave to withdraw everything
|
||||
const withdrawAmount = amount === "max"
|
||||
? (2n ** 256n - 1n)
|
||||
: amount;
|
||||
|
||||
const amountStr = amount === "max" ? `all ${asset}` : `${Number(amount) / 10 ** decimals} ${asset}`;
|
||||
|
||||
return {
|
||||
to: AAVE_V3_POOL[chainId],
|
||||
value: "0",
|
||||
data: encodeAaveWithdraw(assetAddress, withdrawAmount, safeAddress),
|
||||
operation: 0, // Direct call
|
||||
description: `Withdraw ${amountStr} from Aave V3`,
|
||||
steps: [`Withdraw ${amountStr} from Aave V3 Pool`],
|
||||
};
|
||||
}
|
||||
|
||||
// Morpho vault
|
||||
const vault = vaultAddress || MORPHO_VAULTS[chainId]?.find((v) => v.asset === asset)?.address;
|
||||
if (!vault) throw new Error(`No Morpho vault for ${asset} on chain ${chainId}`);
|
||||
|
||||
const vaultName = MORPHO_VAULTS[chainId]?.find((v) => v.address.toLowerCase() === vault.toLowerCase())?.name || "Morpho Vault";
|
||||
|
||||
// For "max", query current share balance
|
||||
let shares: bigint;
|
||||
if (amount === "max") {
|
||||
const rpcUrl = getRpcUrl(chainId);
|
||||
if (!rpcUrl) throw new Error("No RPC URL for chain");
|
||||
const balHex = await rpcCall(rpcUrl, vault, encodeBalanceOf(safeAddress));
|
||||
if (!balHex) throw new Error("Could not query vault balance");
|
||||
shares = BigInt(balHex);
|
||||
if (shares === 0n) throw new Error("No shares to withdraw");
|
||||
} else {
|
||||
// For specific amounts, we'd need previewWithdraw — approximate with shares = amount (ERC-4626 1:1-ish for stables)
|
||||
// This is approximate; the actual share amount may differ slightly
|
||||
shares = amount;
|
||||
}
|
||||
|
||||
const amountStr = amount === "max" ? `all ${asset}` : `${Number(amount) / 10 ** decimals} ${asset}`;
|
||||
|
||||
return {
|
||||
to: vault,
|
||||
value: "0",
|
||||
data: encodeMorphoRedeem(shares, safeAddress, safeAddress),
|
||||
operation: 0,
|
||||
description: `Withdraw ${amountStr} from ${vaultName}`,
|
||||
steps: [`Redeem shares from ${vaultName} for ${amountStr}`],
|
||||
};
|
||||
}
|
||||
|
|
@ -861,6 +861,147 @@ routes.post("/api/crdt-tokens/:tokenId/mint", async (c) => {
|
|||
return c.json({ ok: true, minted: amount, to: toDid });
|
||||
});
|
||||
|
||||
// ── Yield API routes ──
|
||||
import { getYieldRates } from "./lib/yield-rates";
|
||||
import { getYieldPositions } from "./lib/yield-positions";
|
||||
import { buildDepositTransaction, buildWithdrawTransaction } from "./lib/yield-tx-builder";
|
||||
import { computeStrategy } from "./lib/yield-strategy";
|
||||
import type { SupportedYieldChain, YieldProtocol, StablecoinSymbol } from "./lib/yield-protocols";
|
||||
import { STABLECOIN_DECIMALS } from "./lib/yield-protocols";
|
||||
|
||||
const VALID_YIELD_CHAINS = new Set(["1", "8453"]);
|
||||
const VALID_PROTOCOLS = new Set(["aave-v3", "morpho-blue"]);
|
||||
const VALID_ASSETS = new Set(["USDC", "USDT", "DAI"]);
|
||||
|
||||
// GET /api/yield/rates — public, cached
|
||||
routes.get("/api/yield/rates", async (c) => {
|
||||
const chainId = c.req.query("chainId");
|
||||
const protocol = c.req.query("protocol");
|
||||
const asset = c.req.query("asset");
|
||||
|
||||
try {
|
||||
const rates = await getYieldRates({ chainId: chainId || undefined, protocol: protocol || undefined, asset: asset || undefined });
|
||||
c.header("Cache-Control", "public, max-age=300");
|
||||
return c.json({ rates });
|
||||
} catch (err) {
|
||||
console.warn("rwallet: yield rates error", err);
|
||||
return c.json({ error: "Failed to fetch yield rates" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/yield/:address/positions — public, cached
|
||||
routes.get("/api/yield/:address/positions", async (c) => {
|
||||
const address = validateAddress(c);
|
||||
if (!address) return c.json({ error: "Invalid Ethereum address" }, 400);
|
||||
|
||||
try {
|
||||
const positions = await getYieldPositions(address);
|
||||
c.header("Cache-Control", "public, max-age=60");
|
||||
return c.json({ positions });
|
||||
} catch (err) {
|
||||
console.warn("rwallet: yield positions error", err);
|
||||
return c.json({ error: "Failed to fetch yield positions" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/yield/:chainId/:address/build-deposit — auth level 3+
|
||||
routes.post("/api/yield/:chainId/:address/build-deposit", async (c) => {
|
||||
const claims = await verifyWalletAuth(c);
|
||||
if (!claims) return c.json({ error: "Authentication required" }, 401);
|
||||
if (!claims.eid || claims.eid.authLevel < 3) {
|
||||
return c.json({ error: "Elevated authentication required" }, 403);
|
||||
}
|
||||
|
||||
const chainId = c.req.param("chainId");
|
||||
if (!VALID_YIELD_CHAINS.has(chainId)) return c.json({ error: "Unsupported chain for yield" }, 400);
|
||||
|
||||
const address = validateAddress(c);
|
||||
if (!address) return c.json({ error: "Invalid Safe address" }, 400);
|
||||
|
||||
const body = await c.req.json();
|
||||
const { protocol, asset, amount, vaultAddress } = body;
|
||||
|
||||
if (!VALID_PROTOCOLS.has(protocol)) return c.json({ error: "Invalid protocol" }, 400);
|
||||
if (!VALID_ASSETS.has(asset)) return c.json({ error: "Invalid asset" }, 400);
|
||||
if (!amount || typeof amount !== "string" || !/^\d+$/.test(amount)) {
|
||||
return c.json({ error: "Amount must be a numeric string (raw units)" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const tx = await buildDepositTransaction(
|
||||
chainId as SupportedYieldChain,
|
||||
address,
|
||||
protocol as YieldProtocol,
|
||||
asset as StablecoinSymbol,
|
||||
BigInt(amount),
|
||||
vaultAddress,
|
||||
);
|
||||
return c.json({ transaction: tx });
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err.message || "Failed to build deposit transaction" }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/yield/:chainId/:address/build-withdraw — auth level 3+
|
||||
routes.post("/api/yield/:chainId/:address/build-withdraw", async (c) => {
|
||||
const claims = await verifyWalletAuth(c);
|
||||
if (!claims) return c.json({ error: "Authentication required" }, 401);
|
||||
if (!claims.eid || claims.eid.authLevel < 3) {
|
||||
return c.json({ error: "Elevated authentication required" }, 403);
|
||||
}
|
||||
|
||||
const chainId = c.req.param("chainId");
|
||||
if (!VALID_YIELD_CHAINS.has(chainId)) return c.json({ error: "Unsupported chain for yield" }, 400);
|
||||
|
||||
const address = validateAddress(c);
|
||||
if (!address) return c.json({ error: "Invalid Safe address" }, 400);
|
||||
|
||||
const body = await c.req.json();
|
||||
const { protocol, asset, amount, vaultAddress } = body;
|
||||
|
||||
if (!VALID_PROTOCOLS.has(protocol)) return c.json({ error: "Invalid protocol" }, 400);
|
||||
if (!VALID_ASSETS.has(asset)) return c.json({ error: "Invalid asset" }, 400);
|
||||
|
||||
const withdrawAmount = amount === "max" ? "max" as const : BigInt(amount || "0");
|
||||
if (withdrawAmount !== "max" && withdrawAmount === 0n) {
|
||||
return c.json({ error: "Amount required" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const tx = await buildWithdrawTransaction(
|
||||
chainId as SupportedYieldChain,
|
||||
address,
|
||||
protocol as YieldProtocol,
|
||||
asset as StablecoinSymbol,
|
||||
withdrawAmount,
|
||||
vaultAddress,
|
||||
);
|
||||
return c.json({ transaction: tx });
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err.message || "Failed to build withdraw transaction" }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/yield/:address/strategy — auth level 3+
|
||||
routes.get("/api/yield/:address/strategy", async (c) => {
|
||||
const claims = await verifyWalletAuth(c);
|
||||
if (!claims) return c.json({ error: "Authentication required" }, 401);
|
||||
if (!claims.eid || claims.eid.authLevel < 3) {
|
||||
return c.json({ error: "Elevated authentication required" }, 403);
|
||||
}
|
||||
|
||||
const address = validateAddress(c);
|
||||
if (!address) return c.json({ error: "Invalid Ethereum address" }, 400);
|
||||
|
||||
try {
|
||||
const strategy = await computeStrategy(address);
|
||||
return c.json({ strategy });
|
||||
} catch (err) {
|
||||
console.warn("rwallet: yield strategy error", err);
|
||||
return c.json({ error: "Failed to compute strategy" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Page route ──
|
||||
// ── Page routes: subnav tab links ──
|
||||
|
||||
|
|
@ -881,6 +1022,7 @@ function renderWallet(spaceSlug: string, initialView?: string) {
|
|||
routes.get("/wallets", (c) => c.html(renderWallet(c.req.param("space") || "demo")));
|
||||
routes.get("/tokens", (c) => c.html(renderWallet(c.req.param("space") || "demo", "balances")));
|
||||
routes.get("/transactions", (c) => c.html(renderWallet(c.req.param("space") || "demo", "timeline")));
|
||||
routes.get("/yield", (c) => c.html(renderWallet(c.req.param("space") || "demo", "yield")));
|
||||
|
||||
routes.get("/", (c) => c.html(renderWallet(c.req.param("space") || "demo")));
|
||||
|
||||
|
|
@ -912,5 +1054,6 @@ export const walletModule: RSpaceModule = {
|
|||
{ path: "wallets", name: "Wallets", icon: "💳", description: "Connected Safe wallets and EOA accounts" },
|
||||
{ path: "tokens", name: "Tokens", icon: "🪙", description: "Token balances across chains" },
|
||||
{ path: "transactions", name: "Transactions", icon: "📜", description: "Transaction history and transfers" },
|
||||
{ path: "yield", name: "Yield", icon: "📈", description: "Auto-yield on idle stablecoins via Aave V3 and Morpho Blue" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue