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:
Jeff Emmett 2026-03-15 10:17:39 -07:00
parent 2264267ded
commit 3436393bfb
7 changed files with 1593 additions and 6 deletions

View File

@ -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());
}
}

View File

@ -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;
}

View File

@ -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",
};

View File

@ -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;
}

View File

@ -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,
};
}

View File

@ -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}`],
};
}

View File

@ -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" },
],
};