diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index 18ac512..1c76665 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -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> = 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 | 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 `
@@ -1698,6 +1910,133 @@ class FolkWalletViewer extends HTMLElement {
`; } + private renderYieldTab(): string { + if (this.yieldLoading) { + return '
Loading yield data...
'; + } + if (this.yieldError) { + return `
${this.esc(this.yieldError)}
`; + } + + const chainNames: Record = { "1": "Ethereum", "8453": "Base" }; + let html = ""; + + // ── Strategy summary banner ── + if (this.yieldSuggestions.length > 0 || this.yieldTotalDepositedUSD > 0) { + html += `
+
+
+ Idle Stablecoins + ${this.formatUSD(String(this.yieldTotalIdleUSD))} +
+
+ Earning Yield + ${this.formatUSD(String(this.yieldTotalDepositedUSD))} +
+
+ Weighted APY + ${this.yieldWeightedAPY.toFixed(2)}% +
+
+
`; + } + + // ── Positions ── + if (this.yieldPositions.length > 0) { + html += `
+

Active Positions

+
`; + for (const pos of this.yieldPositions) { + const value = Number(BigInt(pos.underlying)) / 10 ** pos.decimals; + html += ` +
+
+ ${pos.protocol === "aave-v3" ? "Aave V3" : "Morpho"} + ${chainNames[pos.chainId] || pos.chainId} + ${pos.asset} +
+
+
+ Deposited + ${value.toLocaleString("en-US", { style: "currency", currency: "USD" })} +
+
+ APY + ${pos.apy.toFixed(2)}% +
+
+ Daily + ${pos.dailyEarnings.toFixed(2)} +
+
+ Annual + ${pos.annualEarnings.toFixed(2)} +
+
+
`; + } + html += `
`; + } + + // ── Suggestions ── + if (this.yieldSuggestions.length > 0) { + html += `
+

Suggestions

`; + 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 += ` +
+ ${s.priority} + ${s.type === "deposit" ? "Deposit" : "Rebalance"} +

${this.esc(s.reason)}

+ +
`; + } + html += `
`; + } + + // ── Rates table ── + if (this.yieldRates.length > 0) { + html += `
+

Available Rates

+ + + + + + + + + + `; + 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 += ` + + + + + + + `; + } + html += `
ProtocolChainAssetAPY7d AvgTVL
${protocolLabel}${chainNames[r.chainId] || r.chainId}${this.esc(r.asset)}${r.apy.toFixed(2)}%${r.apy7d ? r.apy7d.toFixed(2) + "%" : "-"}${r.tvl ? "$" + (r.tvl / 1e6).toFixed(1) + "M" : "-"}
`; + } + + if (!html) { + html = '
No yield data available. Yield is supported on Ethereum and Base for USDC, USDT, and DAI.
'; + } + + return html; + } + private renderBalanceTable(): string { const unified = this.getUnifiedBalances(); if (unified.length === 0) return '
No token balances found.
'; @@ -1795,6 +2134,8 @@ class FolkWalletViewer extends HTMLElement { ${this.activeView === "balances" ? this.renderBalanceTable() + : this.activeView === "yield" + ? this.renderYieldTab() : `
${this.transfersLoading ? '
Loading transfer data...
' : ""}
` @@ -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()); } } diff --git a/modules/rwallet/lib/yield-positions.ts b/modules/rwallet/lib/yield-positions.ts new file mode 100644 index 0000000..14a0d2f --- /dev/null +++ b/modules/rwallet/lib/yield-positions.ts @@ -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 { + 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 { + 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; +} diff --git a/modules/rwallet/lib/yield-protocols.ts b/modules/rwallet/lib/yield-protocols.ts new file mode 100644 index 0000000..1fe5e57 --- /dev/null +++ b/modules/rwallet/lib/yield-protocols.ts @@ -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> = { + "1": { + USDC: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + USDT: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + DAI: "0x6B175474E89094C44Da98b954EedeAC495271d0F", + }, + "8453": { + USDC: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + USDT: "0x0000000000000000000000000000000000000000", // not on Base + DAI: "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb", + }, +}; + +export const STABLECOIN_DECIMALS: Record = { + USDC: 6, + USDT: 6, + DAI: 18, +}; + +// ── Aave V3 ── +export const AAVE_V3_POOL: Record = { + "1": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2", + "8453": "0xA238Dd80C259a72e81d7e4664a9801593F98d1c5", +}; + +// aToken addresses (receipt tokens for deposits) +export const AAVE_ATOKENS: Record>> = { + "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 = { + "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 = { + "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 = { + "1": "Ethereum", + "8453": "Base", +}; diff --git a/modules/rwallet/lib/yield-rates.ts b/modules/rwallet/lib/yield-rates.ts new file mode 100644 index 0000000..d7b98a7 --- /dev/null +++ b/modules/rwallet/lib/yield-rates.ts @@ -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 { + // 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 = { + Ethereum: "1", + Base: "8453", +}; + +const LLAMA_PROJECT_MAP: Record = { + "aave-v3": "aave-v3", + "morpho-blue": "morpho-blue", +}; + +const STABLECOIN_SYMBOLS = new Set(["USDC", "USDT", "DAI"]); + +async function fetchDefiLlamaRates(): Promise { + 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> { + const results = new Map(); + + 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 { + 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 { + 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; +} diff --git a/modules/rwallet/lib/yield-strategy.ts b/modules/rwallet/lib/yield-strategy.ts new file mode 100644 index 0000000..083069a --- /dev/null +++ b/modules/rwallet/lib/yield-strategy.ts @@ -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 = { + "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 { + 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 { + 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 { + // 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, + }; +} diff --git a/modules/rwallet/lib/yield-tx-builder.ts b/modules/rwallet/lib/yield-tx-builder.ts new file mode 100644 index 0000000..0c60f26 --- /dev/null +++ b/modules/rwallet/lib/yield-tx-builder.ts @@ -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 { + 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 { + 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 { + 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}`], + }; +} diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index 9b7c419..1e086ee 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -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" }, ], };