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" },
|
{ 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";
|
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 {
|
interface AllChainBalanceEntry {
|
||||||
chainId: string;
|
chainId: string;
|
||||||
chainName: 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 myWalletBalances: Map<string, Array<{ chainId: string; chainName: string; balances: BalanceItem[] }>> = new Map();
|
||||||
private myWalletsLoading = false;
|
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
|
// Visualization state
|
||||||
private activeView: ViewTab = "balances";
|
private activeView: ViewTab = "balances";
|
||||||
private transfers: Map<string, any> | null = null;
|
private transfers: Map<string, any> | null = null;
|
||||||
|
|
@ -156,7 +202,7 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
// Read initial-view attribute from server route
|
// Read initial-view attribute from server route
|
||||||
const initialView = this.getAttribute("initial-view");
|
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;
|
this.activeView = initialView as ViewTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -261,6 +307,106 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
this.render();
|
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() {
|
private async loadMyWalletBalances() {
|
||||||
const addresses: Array<{ address: string; type: "eoa" | "safe" }> = [];
|
const addresses: Array<{ address: string; type: "eoa" | "safe" }> = [];
|
||||||
|
|
||||||
|
|
@ -699,13 +845,15 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
if (this.activeView === view) return;
|
if (this.activeView === view) return;
|
||||||
this.activeView = view;
|
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.loadTransfers();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.render();
|
this.render();
|
||||||
|
|
||||||
if (view !== "balances") {
|
if (view !== "balances" && view !== "yield") {
|
||||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
requestAnimationFrame(() => this.drawActiveVisualization());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1273,6 +1421,69 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
display: block; max-width: 720px; margin: 20px auto 0; text-align: center;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.hero-title { font-size: 22px; }
|
.hero-title { font-size: 22px; }
|
||||||
.balance-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
.balance-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||||
|
|
@ -1682,13 +1893,14 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
if (!this.hasData()) return "";
|
if (!this.hasData()) return "";
|
||||||
const tabs: { id: ViewTab; label: string }[] = [
|
const tabs: { id: ViewTab; label: string }[] = [
|
||||||
{ id: "balances", label: "Balances" },
|
{ id: "balances", label: "Balances" },
|
||||||
|
{ id: "yield", label: "Yield" },
|
||||||
{ id: "timeline", label: "Timeline" },
|
{ id: "timeline", label: "Timeline" },
|
||||||
{ id: "flow", label: "Flow Map" },
|
{ id: "flow", label: "Flow Map" },
|
||||||
{ id: "sankey", label: "Sankey" },
|
{ id: "sankey", label: "Sankey" },
|
||||||
];
|
];
|
||||||
// Only show viz tabs for Safe wallets (or demo)
|
// Only show viz tabs for Safe wallets (or demo)
|
||||||
const showViz = this.walletType === "safe" || this.isDemo;
|
const showViz = this.walletType === "safe" || this.isDemo;
|
||||||
const visibleTabs = showViz ? tabs : [tabs[0]];
|
const visibleTabs = showViz ? tabs : [tabs[0], tabs[1]];
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="view-tabs">
|
<div class="view-tabs">
|
||||||
|
|
@ -1698,6 +1910,133 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
</div>`;
|
</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 {
|
private renderBalanceTable(): string {
|
||||||
const unified = this.getUnifiedBalances();
|
const unified = this.getUnifiedBalances();
|
||||||
if (unified.length === 0) return '<div class="empty">No token balances found.</div>';
|
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.activeView === "balances"
|
||||||
? this.renderBalanceTable()
|
? this.renderBalanceTable()
|
||||||
|
: this.activeView === "yield"
|
||||||
|
? this.renderYieldTab()
|
||||||
: `<div class="viz-container" id="viz-container">
|
: `<div class="viz-container" id="viz-container">
|
||||||
${this.transfersLoading ? '<div class="loading"><span class="spinner"></span> Loading transfer data...</div>' : ""}
|
${this.transfersLoading ? '<div class="loading"><span class="spinner"></span> Loading transfer data...</div>' : ""}
|
||||||
</div>`
|
</div>`
|
||||||
|
|
@ -1881,8 +2222,18 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
|
|
||||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
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
|
// Draw visualization if active
|
||||||
if (this.activeView !== "balances" && this.hasData()) {
|
if (this.activeView !== "balances" && this.activeView !== "yield" && this.hasData()) {
|
||||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
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 });
|
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 route ──
|
||||||
// ── Page routes: subnav tab links ──
|
// ── 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("/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("/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("/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")));
|
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: "wallets", name: "Wallets", icon: "💳", description: "Connected Safe wallets and EOA accounts" },
|
||||||
{ path: "tokens", name: "Tokens", icon: "🪙", description: "Token balances across chains" },
|
{ path: "tokens", name: "Tokens", icon: "🪙", description: "Token balances across chains" },
|
||||||
{ path: "transactions", name: "Transactions", icon: "📜", description: "Transaction history and transfers" },
|
{ 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