Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-23 16:50:33 -07:00
commit e3a6a45c5a
3 changed files with 390 additions and 2 deletions

View File

@ -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<string, any> | 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 {
</div>`;
}
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 = `<button class="sandbox-toggle ${this.sandboxActive ? "active" : ""}" data-sandbox-toggle>
${this.sandboxActive ? "Close Sandbox" : "Try Sandbox"}
</button>`;
if (!this.sandboxActive) return html;
// Inputs row
html += `<div class="sandbox-inputs">
<label style="font-size:12px;color:var(--rs-text-secondary)">Amount</label>
<input type="number" id="sandbox-amount" value="${this.sandboxPrincipal}" min="1" step="100">
<label style="font-size:12px;color:var(--rs-text-secondary)">Asset</label>
<select id="sandbox-asset">
<option value="USDC" ${this.sandboxAsset === "USDC" ? "selected" : ""}>USDC</option>
<option value="USDT" ${this.sandboxAsset === "USDT" ? "selected" : ""}>USDT</option>
<option value="DAI" ${this.sandboxAsset === "DAI" ? "selected" : ""}>DAI</option>
</select>
<label style="font-size:12px;color:var(--rs-text-secondary)">Chain</label>
<select id="sandbox-chain">
<option value="" ${this.sandboxChain === "" ? "selected" : ""}>All</option>
<option value="1" ${this.sandboxChain === "1" ? "selected" : ""}>Ethereum</option>
<option value="8453" ${this.sandboxChain === "8453" ? "selected" : ""}>Base</option>
</select>
</div>`;
// Time slider
html += `<div class="sandbox-slider-row">
<span class="sandbox-slider-ends">1d</span>
<input type="range" id="sandbox-days" min="1" max="1825" value="${this.sandboxDays}">
<span class="sandbox-slider-ends">5yr</span>
<span class="sandbox-slider-label">${this.formatDaysLabel(this.sandboxDays)}</span>
</div>`;
// Protocol comparison
const comparisons = this.sandboxChain
? this.sandboxComparisons.filter((c) => c.chainId === this.sandboxChain)
: this.sandboxComparisons;
if (comparisons.length > 0) {
html += `<div class="sandbox-compare">`;
for (const c of comparisons) {
html += `<div class="sandbox-compare-row">
<span class="sandbox-compare-label">${this.esc(c.label)}</span>
<span class="sandbox-compare-apy">${c.apy.toFixed(2)}%</span>
<span class="sandbox-live-badge ${c.isLive ? "live" : "est"}">${c.isLive ? "LIVE" : "EST"}</span>
<span class="sandbox-earnings">+$${c.earnings < 1 ? c.earnings.toFixed(2) : c.earnings.toFixed(0)}</span>
<span class="sandbox-balance">$${c.balance.toLocaleString("en-US", { maximumFractionDigits: 0 })}</span>
</div>`;
}
html += `</div>`;
}
// 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 += `<div class="sandbox-milestones">`;
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 += `<div class="sandbox-milestone">
<span class="sandbox-milestone-label">${milestoneLabels[i]}</span>
<span class="sandbox-milestone-value">${val}</span>
</div>`;
}
html += `</div>`;
return `<div class="sandbox-panel">${html}</div>`;
}
private renderYieldTab(): string {
if (this.yieldLoading) {
return '<div class="loading"><span class="spinner"></span> Loading yield data...</div>';
@ -2148,7 +2337,7 @@ class FolkWalletViewer extends HTMLElement {
}
const chainNames: Record<string, string> = { "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", () => {

View File

@ -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 ──

View File

@ -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<string, number> = {
"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<string, string> = {
"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;
}