diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index 97e1a96..b81e691 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -9,7 +9,9 @@ import { transformToTimelineData, transformToSankeyData, transformToMultichainData, explorerLink, txExplorerLink } from "../lib/data-transform"; import type { TimelineEntry, SankeyData, MultichainData, TransferRecord } from "../lib/data-transform"; import { loadD3, renderTimeline, renderFlowChart, renderSankey } from "../lib/wallet-viz"; -import { DEMO_TIMELINE_DATA, DEMO_SANKEY_DATA, DEMO_MULTICHAIN_DATA } from "../lib/wallet-demo-data"; +import { DEMO_TIMELINE_DATA, DEMO_SANKEY_DATA, DEMO_MULTICHAIN_DATA, DEMO_YIELD_RATES } from "../lib/wallet-demo-data"; +import { buildProtocolComparisons, computeYieldAtDays, resolveApy } from "../lib/yield-sandbox"; +import type { ProtocolComparison, SandboxAsset } from "../lib/yield-sandbox"; import { TourEngine } from "../../../shared/tour-engine"; import { WalletLocalFirstClient } from "../local-first-client"; import type { WalletDoc, WatchedAddress } from "../schemas"; @@ -172,6 +174,14 @@ class FolkWalletViewer extends HTMLElement { private yieldDepositInProgress = false; private yieldError = ""; + // Sandbox simulator state + private sandboxActive = false; + private sandboxPrincipal = 10000; + private sandboxAsset: SandboxAsset = "USDC"; + private sandboxChain: "1" | "8453" | "" = ""; + private sandboxDays = 365; + private sandboxComparisons: ProtocolComparison[] = []; + // Visualization state private activeView: ViewTab = "balances"; private transfers: Map | null = null; @@ -407,6 +417,7 @@ class FolkWalletViewer extends HTMLElement { } this.yieldLoading = false; + if (this.sandboxActive) this.recomputeSandbox(); this.render(); } @@ -559,6 +570,21 @@ class FolkWalletViewer extends HTMLElement { sankey: DEMO_SANKEY_DATA, multichain: DEMO_MULTICHAIN_DATA, }; + // Seed sandbox with demo yield rates + this.yieldRates = DEMO_YIELD_RATES.map((r) => ({ + protocol: r.protocol, + chainId: r.chainId, + asset: r.asset, + assetAddress: r.assetAddress, + vaultAddress: r.vaultAddress, + apy: r.apy, + tvl: r.tvl, + vaultName: r.vaultName, + })); + if (this.activeView === "yield") { + this.sandboxActive = true; + this.recomputeSandbox(); + } this.render(); } @@ -1686,6 +1712,75 @@ class FolkWalletViewer extends HTMLElement { .yield-rates-table .col-asset { width: 12%; } .yield-rates-table .col-num { width: 17%; text-align: right; font-family: monospace; } + /* ── Sandbox panel ── */ + .sandbox-panel { + background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); + border-radius: 12px; padding: 16px 20px; margin-bottom: 20px; + } + .sandbox-toggle { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 14px; border-radius: 8px; border: 1px solid var(--rs-accent); + background: transparent; color: var(--rs-accent); cursor: pointer; + font-size: 12px; font-weight: 600; transition: all 0.2s; margin-bottom: 12px; + } + .sandbox-toggle:hover { background: rgba(20,184,166,0.1); } + .sandbox-toggle.active { background: var(--rs-accent); color: #000; } + .sandbox-inputs { + display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; + } + .sandbox-inputs input, .sandbox-inputs select { + padding: 6px 10px; border-radius: 6px; border: 1px solid var(--rs-border); + background: var(--rs-bg); color: var(--rs-text-primary); font-size: 13px; + font-family: monospace; + } + .sandbox-inputs input { width: 120px; } + .sandbox-inputs select { min-width: 80px; } + .sandbox-slider-row { + display: flex; align-items: center; gap: 12px; margin-bottom: 14px; + } + .sandbox-slider-row input[type="range"] { + flex: 1; accent-color: var(--rs-accent); cursor: pointer; + } + .sandbox-slider-label { + font-size: 12px; color: var(--rs-text-secondary); min-width: 60px; text-align: right; + } + .sandbox-slider-ends { + font-size: 10px; color: var(--rs-text-muted); white-space: nowrap; + } + .sandbox-compare { + display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; + } + .sandbox-compare-row { + display: grid; grid-template-columns: 1fr 80px 50px 100px 100px; + gap: 8px; align-items: center; padding: 8px 10px; border-radius: 8px; + background: var(--rs-bg); font-size: 13px; + } + .sandbox-compare-row:first-child { background: rgba(20,184,166,0.08); } + .sandbox-compare-label { font-weight: 500; color: var(--rs-text-primary); } + .sandbox-compare-apy { font-family: monospace; font-weight: 700; color: var(--rs-success); text-align: right; } + .sandbox-live-badge { + font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px; + text-transform: uppercase; text-align: center; + } + .sandbox-live-badge.live { background: rgba(102,187,106,0.15); color: #66bb6a; } + .sandbox-live-badge.est { background: rgba(255,167,38,0.15); color: #ffa726; } + .sandbox-earnings { font-family: monospace; text-align: right; color: var(--rs-success); } + .sandbox-balance { font-family: monospace; text-align: right; color: var(--rs-text-secondary); } + .sandbox-milestones { + display: grid; grid-template-columns: repeat(8, 1fr); gap: 6px; text-align: center; + } + .sandbox-milestone { + display: flex; flex-direction: column; gap: 2px; padding: 6px 4px; + border-radius: 6px; background: var(--rs-bg); + } + .sandbox-milestone-label { font-size: 10px; color: var(--rs-text-muted); text-transform: uppercase; } + .sandbox-milestone-value { font-size: 12px; font-weight: 600; color: var(--rs-success); font-family: monospace; } + @media (max-width: 640px) { + .sandbox-milestones { grid-template-columns: repeat(4, 1fr); } + .sandbox-compare-row { grid-template-columns: 1fr 60px 40px 80px; } + .sandbox-balance { display: none; } + } + @media (max-width: 768px) { .hero-title { font-size: 22px; } .balance-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; } @@ -2139,6 +2234,100 @@ class FolkWalletViewer extends HTMLElement { `; } + private recomputeSandbox() { + const liveRates = this.yieldRates.map((r) => ({ + protocol: r.protocol, + chainId: r.chainId, + asset: r.asset, + apy: r.apy, + })); + this.sandboxComparisons = buildProtocolComparisons( + this.sandboxAsset, + this.sandboxPrincipal, + this.sandboxDays, + liveRates.length ? liveRates : undefined, + ); + } + + private formatDaysLabel(days: number): string { + if (days < 7) return `${days} day${days > 1 ? "s" : ""}`; + if (days < 30) return `${Math.round(days / 7)} week${days >= 14 ? "s" : ""}`; + if (days < 365) return `${Math.round(days / 30)} month${days >= 60 ? "s" : ""}`; + const y = days / 365; + return y === Math.floor(y) ? `${y} year${y > 1 ? "s" : ""}` : `${y.toFixed(1)} years`; + } + + private renderSandboxPanel(): string { + let html = ``; + + if (!this.sandboxActive) return html; + + // Inputs row + html += `
+ + + + + + +
`; + + // Time slider + html += `
+ 1d + + 5yr + ${this.formatDaysLabel(this.sandboxDays)} +
`; + + // Protocol comparison + const comparisons = this.sandboxChain + ? this.sandboxComparisons.filter((c) => c.chainId === this.sandboxChain) + : this.sandboxComparisons; + + if (comparisons.length > 0) { + html += `
`; + for (const c of comparisons) { + html += `
+ ${this.esc(c.label)} + ${c.apy.toFixed(2)}% + ${c.isLive ? "LIVE" : "EST"} + +$${c.earnings < 1 ? c.earnings.toFixed(2) : c.earnings.toFixed(0)} + $${c.balance.toLocaleString("en-US", { maximumFractionDigits: 0 })} +
`; + } + html += `
`; + } + + // Milestones (use top comparison APY or first available) + const bestApy = comparisons[0]?.apy || this.sandboxComparisons[0]?.apy || 4; + const milestonePoints = [1, 7, 30, 90, 180, 365, 730, 1825]; + const milestoneLabels = ["1d", "7d", "30d", "90d", "6mo", "1yr", "2yr", "5yr"]; + + html += `
`; + for (let i = 0; i < milestonePoints.length; i++) { + const pt = computeYieldAtDays(this.sandboxPrincipal, bestApy, milestonePoints[i]); + const val = pt.earnings < 1 ? `$${pt.earnings.toFixed(2)}` : `$${pt.earnings.toFixed(0)}`; + html += `
+ ${milestoneLabels[i]} + ${val} +
`; + } + html += `
`; + + return `
${html}
`; + } + private renderYieldTab(): string { if (this.yieldLoading) { return '
Loading yield data...
'; @@ -2148,7 +2337,7 @@ class FolkWalletViewer extends HTMLElement { } const chainNames: Record = { "1": "Ethereum", "8453": "Base" }; - let html = ""; + let html = this.renderSandboxPanel(); // ── Strategy summary banner ── if (this.yieldSuggestions.length > 0 || this.yieldTotalDepositedUSD > 0) { @@ -2476,6 +2665,35 @@ class FolkWalletViewer extends HTMLElement { this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); + // Sandbox listeners + this.shadow.querySelector("[data-sandbox-toggle]")?.addEventListener("click", () => { + this.sandboxActive = !this.sandboxActive; + if (this.sandboxActive) this.recomputeSandbox(); + this.render(); + }); + this.shadow.querySelector("#sandbox-amount")?.addEventListener("input", (e) => { + const val = parseFloat((e.target as HTMLInputElement).value); + if (!isNaN(val) && val > 0) { + this.sandboxPrincipal = val; + this.recomputeSandbox(); + this.render(); + } + }); + this.shadow.querySelector("#sandbox-asset")?.addEventListener("change", (e) => { + this.sandboxAsset = (e.target as HTMLSelectElement).value as SandboxAsset; + this.recomputeSandbox(); + this.render(); + }); + this.shadow.querySelector("#sandbox-chain")?.addEventListener("change", (e) => { + this.sandboxChain = (e.target as HTMLSelectElement).value as "1" | "8453" | ""; + this.render(); + }); + this.shadow.querySelector("#sandbox-days")?.addEventListener("input", (e) => { + this.sandboxDays = parseInt((e.target as HTMLInputElement).value, 10); + this.recomputeSandbox(); + this.render(); + }); + // Yield deposit buttons this.shadow.querySelectorAll("[data-yield-deposit]").forEach((btn) => { btn.addEventListener("click", () => { diff --git a/modules/rwallet/lib/wallet-demo-data.ts b/modules/rwallet/lib/wallet-demo-data.ts index 0d7271d..c0b7709 100644 --- a/modules/rwallet/lib/wallet-demo-data.ts +++ b/modules/rwallet/lib/wallet-demo-data.ts @@ -3,6 +3,19 @@ */ import type { TimelineEntry, SankeyData, MultichainData, FlowEntry, TransferRecord } from "./data-transform"; +import type { YieldOpportunity } from "./yield-protocols"; + +// ── Demo yield rates for sandbox mode ── + +export const DEMO_YIELD_RATES: YieldOpportunity[] = [ + { protocol: "morpho-blue", chainId: "8453", asset: "USDC", assetAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", vaultAddress: "0xc1256Ae5FF1cf2719D4937adb3bbCCab2E00A2Ca", apy: 5.52, tvl: 420_000_000, vaultName: "Moonwell Flagship USDC" }, + { protocol: "morpho-blue", chainId: "1", asset: "USDC", assetAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", vaultAddress: "0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB", apy: 5.23, tvl: 680_000_000, vaultName: "Steakhouse USDC" }, + { protocol: "morpho-blue", chainId: "1", asset: "USDT", assetAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", vaultAddress: "0x2371e134e3455e0593363cBF89d3b6cf53740618", apy: 4.87, tvl: 310_000_000, vaultName: "Steakhouse USDT" }, + { protocol: "aave-v3", chainId: "8453", asset: "USDC", assetAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", vaultAddress: "0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB", apy: 4.12, tvl: 290_000_000 }, + { protocol: "aave-v3", chainId: "1", asset: "DAI", assetAddress: "0x6B175474E89094C44Da98b954EedeAC495271d0F", vaultAddress: "0x018008bfb33d285247A21d44E50697654f754e63", apy: 3.95, tvl: 520_000_000 }, + { protocol: "aave-v3", chainId: "1", asset: "USDC", assetAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", vaultAddress: "0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c", apy: 3.82, tvl: 1_200_000_000 }, + { protocol: "aave-v3", chainId: "1", asset: "USDT", assetAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", vaultAddress: "0x23878914EFE38d27C4D67Ab83ed1b93A74D4086a", apy: 3.61, tvl: 890_000_000 }, +]; // ── Timeline: ~30 entries over ~2 years ── diff --git a/modules/rwallet/lib/yield-sandbox.ts b/modules/rwallet/lib/yield-sandbox.ts new file mode 100644 index 0000000..58245bd --- /dev/null +++ b/modules/rwallet/lib/yield-sandbox.ts @@ -0,0 +1,157 @@ +/** + * Yield Sandbox — pure client-side compound interest simulator. + * No server dependencies. Uses live DeFi Llama rates when available, + * falls back to hardcoded mock rates. + */ + +export type SandboxProtocol = "aave-v3" | "morpho-blue"; +export type SandboxChain = "1" | "8453"; +export type SandboxAsset = "USDC" | "USDT" | "DAI"; + +export interface YieldPoint { + days: number; + balance: number; + earnings: number; + dailyRate: number; +} + +export interface SandboxResult { + principal: number; + apy: number; + isLive: boolean; + protocol: string; + chainId: string; + asset: string; + milestones: YieldPoint[]; +} + +export interface ProtocolComparison { + protocol: SandboxProtocol; + chainId: SandboxChain; + label: string; + apy: number; + isLive: boolean; + earnings: number; + balance: number; +} + +interface LiveRate { + protocol: string; + chainId: string; + asset: string; + apy: number; +} + +// ── Realistic fallback APYs (updated periodically) ── + +const MOCK_FALLBACK_RATES: Record = { + "aave-v3:1:USDC": 3.82, + "aave-v3:1:USDT": 3.61, + "aave-v3:1:DAI": 3.95, + "aave-v3:8453:USDC": 4.12, + "aave-v3:8453:DAI": 3.74, + "morpho-blue:1:USDC": 5.23, + "morpho-blue:1:USDT": 4.87, + "morpho-blue:8453:USDC": 5.52, +}; + +const MILESTONE_DAYS = [1, 7, 30, 90, 180, 365, 730, 1825]; + +const PROTOCOL_LABELS: Record = { + "aave-v3:1": "Aave V3 Ethereum", + "aave-v3:8453": "Aave V3 Base", + "morpho-blue:1": "Morpho Ethereum", + "morpho-blue:8453": "Morpho Base", +}; + +// ── Core compound interest ── + +export function computeYieldAtDays( + principal: number, + apy: number, + days: number, + compoundFreq = 365, +): YieldPoint { + const r = apy / 100; + const t = days / 365; + const balance = principal * Math.pow(1 + r / compoundFreq, compoundFreq * t); + const earnings = balance - principal; + const dailyRate = days > 0 ? earnings / days : 0; + return { days, balance, earnings, dailyRate }; +} + +// ── Full milestone simulation ── + +export function simulateYield(config: { + principal: number; + apy: number; + isLive: boolean; + protocol: string; + chainId: string; + asset: string; + compoundFreq?: number; +}): SandboxResult { + const freq = config.compoundFreq ?? 365; + const milestones = MILESTONE_DAYS.map((d) => + computeYieldAtDays(config.principal, config.apy, d, freq), + ); + return { + principal: config.principal, + apy: config.apy, + isLive: config.isLive, + protocol: config.protocol, + chainId: config.chainId, + asset: config.asset, + milestones, + }; +} + +// ── APY resolution: live > mock ── + +export function resolveApy( + protocol: string, + chainId: string, + asset: string, + liveRates?: LiveRate[], +): { apy: number; isLive: boolean } { + if (liveRates?.length) { + const match = liveRates.find( + (r) => r.protocol === protocol && r.chainId === chainId && r.asset === asset, + ); + if (match && match.apy > 0) return { apy: match.apy, isLive: true }; + } + const key = `${protocol}:${chainId}:${asset}`; + const fallback = MOCK_FALLBACK_RATES[key]; + return fallback != null ? { apy: fallback, isLive: false } : { apy: 0, isLive: false }; +} + +// ── Cross-protocol comparison ── + +export function buildProtocolComparisons( + asset: SandboxAsset, + principal: number, + days: number, + liveRates?: LiveRate[], +): ProtocolComparison[] { + const combos: Array<{ protocol: SandboxProtocol; chainId: SandboxChain }> = [ + { protocol: "aave-v3", chainId: "1" }, + { protocol: "aave-v3", chainId: "8453" }, + { protocol: "morpho-blue", chainId: "1" }, + { protocol: "morpho-blue", chainId: "8453" }, + ]; + + const results: ProtocolComparison[] = []; + + for (const { protocol, chainId } of combos) { + const { apy, isLive } = resolveApy(protocol, chainId, asset, liveRates); + if (apy <= 0) continue; + + const { balance, earnings } = computeYieldAtDays(principal, apy, days); + const label = PROTOCOL_LABELS[`${protocol}:${chainId}`] || `${protocol} ${chainId}`; + + results.push({ protocol, chainId, label, apy, isLive, earnings, balance }); + } + + results.sort((a, b) => b.apy - a.apy); + return results; +}