From 2bfd674d0ecf95c06a31292c02ee7aa63c395c26 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 14:03:35 -0700 Subject: [PATCH 1/2] =?UTF-8?q?refactor(rwallet):=20consolidate=20tabs=20?= =?UTF-8?q?=E2=80=94=20remove=20Yield,=20merge=20viz=20into=20Budget=20+?= =?UTF-8?q?=20Flows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify rWallet from 5 internal tabs to 3: Token Balances, Budget Visualization (default), and Flows (Sankey + timeline scrubber with play/pause). Remove Yield shell-level outputPath and route. Budget view auto-loads transfer data on entry. Co-Authored-By: Claude Opus 4.6 --- .../rwallet/components/folk-wallet-viewer.ts | 476 +++++++++++++++--- modules/rwallet/lib/defi-positions.ts | 165 ++++++ modules/rwallet/lib/price-feed.ts | 201 ++++++++ modules/rwallet/mod.ts | 39 +- 4 files changed, 809 insertions(+), 72 deletions(-) create mode 100644 modules/rwallet/lib/defi-positions.ts create mode 100644 modules/rwallet/lib/price-feed.ts diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index 3bbd11e..1295d11 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -88,7 +88,7 @@ const EXAMPLE_WALLETS = [ { name: "Vitalik.eth", address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", type: "EOA" }, ]; -type ViewTab = "balances" | "timeline" | "flow" | "sankey" | "yield"; +type ViewTab = "balances" | "budget" | "flows"; interface YieldRate { protocol: string; @@ -161,6 +161,11 @@ class FolkWalletViewer extends HTMLElement { private crdtBalances: Array<{ tokenId: string; name: string; symbol: string; decimals: number; icon: string; color: string; balance: number }> = []; private crdtLoading = false; + // DeFi positions (Zerion) + private defiPositions: Array<{ protocol: string; type: string; chain: string; chainId: string; tokens: Array<{ symbol: string; amount: number; valueUSD: number }>; totalValueUSD: number }> = []; + private defiTotalUSD = 0; + private defiLoading = false; + private myWalletBalances: Map> = new Map(); private myWalletsLoading = false; @@ -188,7 +193,7 @@ class FolkWalletViewer extends HTMLElement { private detailsModalOpen = false; // Visualization state - private activeView: ViewTab = "balances"; + private activeView: ViewTab = "budget"; private transfers: Map | null = null; private transfersLoading = false; private d3Ready = false; @@ -198,6 +203,10 @@ class FolkWalletViewer extends HTMLElement { multichain?: MultichainData; } = {}; + // Flows scrubber state + private flowsScrubberPos = -1; // -1 = show all + private flowsPlayInterval: ReturnType | null = null; + // Guided tour private _tour!: TourEngine; private static readonly TOUR_STEPS = [ @@ -227,7 +236,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", "yield"].includes(initialView)) { + if (initialView && ["balances", "budget", "flows"].includes(initialView)) { this.activeView = initialView as ViewTab; } @@ -240,18 +249,13 @@ class FolkWalletViewer extends HTMLElement { this.checkAuthState(); this.initWalletSync(space); - if (this.activeView === "yield") { - this.render(); - this.loadYieldData(); - } else { - // Auto-load address from passkey or linked wallet - if (!this.address && this.passKeyEOA) { - this.address = this.passKeyEOA; - } - - this.render(); - if (this.address) this.detectChains(); + // Auto-load address from passkey or linked wallet + if (!this.address && this.passKeyEOA) { + this.address = this.passKeyEOA; } + + this.render(); + if (this.address) this.detectChains(); } if (!localStorage.getItem("rwallet_tour_done")) { setTimeout(() => this._tour.start(), 1200); @@ -261,6 +265,10 @@ class FolkWalletViewer extends HTMLElement { disconnectedCallback() { this._stopPresence?.(); + if (this.flowsPlayInterval) { + clearInterval(this.flowsPlayInterval); + this.flowsPlayInterval = null; + } } private checkAuthState() { @@ -377,6 +385,24 @@ class FolkWalletViewer extends HTMLElement { this.render(); } + private async loadDefiPositions(address?: string) { + const addr = address || this.address; + if (!addr) return; + this.defiLoading = true; + this.render(); + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/defi/${addr}/positions`); + if (res.ok) { + const data = await res.json(); + this.defiPositions = data.positions || []; + this.defiTotalUSD = data.totalUSD || 0; + } + } catch {} + this.defiLoading = false; + this.render(); + } + // ── Yield data loading ── private async loadYieldData() { @@ -588,10 +614,6 @@ class FolkWalletViewer extends HTMLElement { tvl: r.tvl, vaultName: r.vaultName, })); - if (this.activeView === "yield") { - this.sandboxActive = true; - this.recomputeSandbox(); - } this.render(); } @@ -650,11 +672,18 @@ class FolkWalletViewer extends HTMLElement { this.loading = false; this.addressBarExpanded = false; - // Default to timeline view and auto-load transfers when wallet data is available + // Fire DeFi positions fetch in background (non-blocking) + if (this.address && this.hasData()) { + this.loadDefiPositions(); + } + + // Default to budget view and auto-load transfers when wallet data is available if (this.hasData()) { const showViz = this.walletType === "safe" || this.isDemo || (this.isAuthenticated && this.crdtBalances.length > 0); - if (showViz) { - this.activeView = "timeline"; + if (showViz && this.activeView !== "balances") { + if (this.activeView !== "budget" && this.activeView !== "flows") { + this.activeView = "budget"; + } this.render(); this.loadTransfers(); return; @@ -919,7 +948,7 @@ class FolkWalletViewer extends HTMLElement { chainColorMap["crdt"] = "#22c55e"; switch (this.activeView) { - case "timeline": + case "budget": if (this.vizData.timeline && this.vizData.timeline.length > 0) { renderTimeline(container, this.vizData.timeline, { chainColors: chainColorMap }); } else { @@ -927,28 +956,174 @@ class FolkWalletViewer extends HTMLElement { } break; - case "flow": - if (this.vizData.multichain) { - const mc = this.vizData.multichain; - renderFlowChart(container, mc.flowData["all"] || [], mc.chainStats["all"], { - chainColors: chainColorMap, - safeAddress: this.address, - }); - } else { - container.innerHTML = '
No flow data available.
'; - } - break; - - case "sankey": - if (this.vizData.sankey && this.vizData.sankey.links.length > 0) { - renderSankey(container, this.vizData.sankey); - } else { - container.innerHTML = '
No Sankey data available for the selected chain.
'; - } + case "flows": + this.drawFlowsWithScrubber(container, chainColorMap); break; } } + private drawFlowsWithScrubber(container: HTMLElement, chainColorMap: Record) { + const timeline = this.vizData.timeline; + if (!timeline || timeline.length === 0) { + // Fall back to sankey-only if no timeline but sankey data exists + if (this.vizData.sankey && this.vizData.sankey.links.length > 0) { + renderSankey(container, this.vizData.sankey); + } else { + container.innerHTML = '
No flow data available. Transfer data may still be loading.
'; + } + return; + } + + container.innerHTML = ""; + + // Sankey container + const sankeyDiv = document.createElement("div"); + sankeyDiv.id = "flows-sankey"; + container.appendChild(sankeyDiv); + + // Scrubber controls + const scrubberWrap = document.createElement("div"); + scrubberWrap.className = "flows-scrubber"; + scrubberWrap.innerHTML = ` +
+ + + ${this.flowsScrubberPos < 0 ? "All transactions" : ""} +
+
+ `; + container.appendChild(scrubberWrap); + + const rangeInput = scrubberWrap.querySelector("#flows-range") as HTMLInputElement; + const dateLabel = scrubberWrap.querySelector("#flows-date-label") as HTMLElement; + const txDetail = scrubberWrap.querySelector("#flows-tx-detail") as HTMLElement; + const playBtn = scrubberWrap.querySelector("#flows-play") as HTMLButtonElement; + + const renderAtPosition = (pos: number) => { + this.flowsScrubberPos = pos; + const slicedTimeline = pos < 0 ? timeline : timeline.slice(0, pos + 1); + + // Rebuild sankey data from sliced timeline + const sankeyData = this.buildSankeyFromTimeline(slicedTimeline); + sankeyDiv.innerHTML = ""; + + if (sankeyData && sankeyData.links.length > 0) { + renderSankey(sankeyDiv, sankeyData); + } else { + sankeyDiv.innerHTML = '
Move the scrubber to include transactions.
'; + } + + // Update label + if (pos < 0) { + dateLabel.textContent = `All transactions (${timeline.length})`; + txDetail.innerHTML = ""; + } else { + const tx = timeline[pos]; + const dateStr = tx.date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); + dateLabel.textContent = `${pos + 1} of ${timeline.length} — ${dateStr}`; + + const color = tx.type === "in" ? "#4ade80" : "#f87171"; + const sign = tx.type === "in" ? "+" : "-"; + const usd = tx.usd >= 1000 ? Math.round(tx.usd).toLocaleString() : tx.usd.toFixed(2); + const peer = tx.type === "in" ? (tx.from || "Unknown") : (tx.to || "Unknown"); + const peerLabel = tx.type === "in" ? "From" : "To"; + txDetail.innerHTML = ` + ${sign}$${usd} + · + ${tx.amount.toLocaleString(undefined, { maximumFractionDigits: 4 })} ${tx.token} + · + ${peerLabel}: ${peer.length > 14 ? peer.slice(0, 6) + "..." + peer.slice(-4) : peer} + `; + } + }; + + // Initial render + renderAtPosition(this.flowsScrubberPos); + + // Scrubber input handler + rangeInput.addEventListener("input", () => { + const pos = parseInt(rangeInput.value, 10); + renderAtPosition(pos); + }); + + // Play/pause handler + playBtn.addEventListener("click", () => { + if (this.flowsPlayInterval) { + clearInterval(this.flowsPlayInterval); + this.flowsPlayInterval = null; + playBtn.textContent = "▶"; + return; + } + + playBtn.textContent = "⏸"; + // Start from beginning if at end or showing all + if (this.flowsScrubberPos >= timeline.length - 1 || this.flowsScrubberPos < 0) { + this.flowsScrubberPos = -1; + } + + this.flowsPlayInterval = setInterval(() => { + const next = this.flowsScrubberPos + 1; + if (next >= timeline.length) { + clearInterval(this.flowsPlayInterval!); + this.flowsPlayInterval = null; + playBtn.textContent = "▶"; + return; + } + rangeInput.value = String(next); + renderAtPosition(next); + }, 600); + }); + } + + private buildSankeyFromTimeline(entries: TimelineEntry[]): SankeyData | null { + if (entries.length === 0) return null; + + const nodeMap = new Map(); + const links: { source: number; target: number; value: number; token: string }[] = []; + + const getIdx = (name: string) => { + if (!nodeMap.has(name)) nodeMap.set(name, nodeMap.size); + return nodeMap.get(name)!; + }; + + const walletLabel = "Wallet"; + getIdx(walletLabel); + + for (const tx of entries) { + if (tx.type === "in") { + const fromLabel = tx.from ? (tx.from.length > 14 ? tx.from.slice(0, 6) + "..." + tx.from.slice(-4) : tx.from) : "Unknown"; + const src = getIdx(fromLabel); + const tgt = getIdx(walletLabel); + links.push({ source: src, target: tgt, value: Math.max(tx.usd, 0.01), token: tx.token }); + } else { + const toLabel = tx.to ? (tx.to.length > 14 ? tx.to.slice(0, 6) + "..." + tx.to.slice(-4) : tx.to) : "Unknown"; + const src = getIdx(walletLabel); + const tgt = getIdx(toLabel); + links.push({ source: src, target: tgt, value: Math.max(tx.usd, 0.01), token: tx.token }); + } + } + + // Aggregate duplicate links (same source→target) + const linkKey = (l: typeof links[0]) => `${l.source}->${l.target}`; + const aggregated = new Map(); + for (const l of links) { + const k = linkKey(l); + if (aggregated.has(k)) { + const existing = aggregated.get(k)!; + existing.value += l.value; + } else { + aggregated.set(k, { ...l }); + } + } + + const nodes = Array.from(nodeMap.entries()).map(([name, _idx]) => ({ + name, + type: name === walletLabel ? "wallet" as const : (entries.some(tx => tx.type === "in" && ((tx.from || "Unknown").startsWith(name.slice(0, 6)))) ? "source" as const : "target" as const), + })); + + return { nodes, links: Array.from(aggregated.values()) }; + } + private renderTransactionTables(): string { const mc = this.vizData.multichain; if (!mc || (!mc.allTransfers.incoming.length && !mc.allTransfers.outgoing.length)) return ""; @@ -1061,15 +1236,13 @@ class FolkWalletViewer extends HTMLElement { if (this.activeView === view) return; this.activeView = view; - if (view === "yield" && this.yieldRates.length === 0) { - this.loadYieldData(); - } else if (view !== "balances" && view !== "yield" && !this.transfers && !this.isDemo) { + if (view !== "balances" && !this.transfers && !this.isDemo) { this.loadTransfers(); } this.render(); - if (view !== "balances" && view !== "yield") { + if (view !== "balances") { requestAnimationFrame(() => this.drawActiveVisualization()); } } @@ -1498,6 +1671,53 @@ class FolkWalletViewer extends HTMLElement { /* ── Viz container ── */ .viz-container { min-height: 200px; } + /* ── Flows scrubber ── */ + .flows-scrubber { + margin-top: 16px; padding: 14px 18px; + background: var(--rs-bg-surface, rgba(255,255,255,0.03)); + border: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.1)); + border-radius: 10px; + } + .scrubber-row { + display: flex; align-items: center; gap: 12px; + } + .scrubber-btn { + width: 36px; height: 36px; border-radius: 50%; + border: 1px solid var(--rs-border, rgba(255,255,255,0.15)); + background: var(--rs-bg-hover, rgba(255,255,255,0.06)); + color: var(--rs-text-primary, #e0e0e0); + font-size: 14px; cursor: pointer; display: flex; + align-items: center; justify-content: center; + transition: background 0.2s; + } + .scrubber-btn:hover { background: var(--rs-bg-active, rgba(255,255,255,0.12)); } + .scrubber-range { + flex: 1; height: 6px; -webkit-appearance: none; appearance: none; + background: var(--rs-border-subtle, rgba(255,255,255,0.1)); + border-radius: 3px; outline: none; cursor: pointer; + } + .scrubber-range::-webkit-slider-thumb { + -webkit-appearance: none; width: 18px; height: 18px; + border-radius: 50%; background: var(--rs-accent, #14b8a6); + border: 2px solid var(--rs-bg-surface, #1a1a2e); + cursor: grab; + } + .scrubber-range::-moz-range-thumb { + width: 18px; height: 18px; border-radius: 50%; + background: var(--rs-accent, #14b8a6); + border: 2px solid var(--rs-bg-surface, #1a1a2e); + cursor: grab; + } + .scrubber-label { + font-size: 0.82rem; color: var(--rs-text-secondary, #888); + min-width: 180px; text-align: right; white-space: nowrap; + } + .scrubber-detail { + margin-top: 8px; font-size: 0.85rem; min-height: 1.5em; + color: var(--rs-text-primary, #e0e0e0); display: flex; + align-items: center; flex-wrap: wrap; + } + /* ── Transaction tables ── */ .tx-tables { margin-top: 20px; } .tx-section { background: var(--rs-bg-surface, rgba(255,255,255,0.03)); border: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.1)); border-radius: 10px; margin-bottom: 12px; } @@ -1559,6 +1779,86 @@ class FolkWalletViewer extends HTMLElement { } .local-tokens-section table tr:last-child td { border-bottom: none; } + /* ── DeFi Positions ── */ + .defi-section { + margin-top: 20px; + padding: 0 4px; + } + .defi-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + .defi-header h3 { + margin: 0; + font-size: 1rem; + color: #e0e0e0; + } + .defi-total { + font-size: 1.1rem; + font-weight: 700; + color: #4ade80; + font-family: monospace; + } + .defi-protocol-card { + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 10px; + padding: 12px; + margin-bottom: 10px; + } + .defi-protocol-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 0.9rem; + } + .defi-proto-total { + font-family: monospace; + font-weight: 600; + color: #ccc; + } + .defi-position-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + border-top: 1px solid rgba(255,255,255,0.05); + font-size: 0.82rem; + flex-wrap: wrap; + } + .defi-type-badge { + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 4px; + font-weight: 600; + text-transform: uppercase; + white-space: nowrap; + } + .defi-chain { + color: #999; + font-size: 0.78rem; + } + .defi-tokens { + flex: 1; + display: flex; + gap: 6px; + flex-wrap: wrap; + } + .defi-token { + font-family: monospace; + font-size: 0.78rem; + color: #bbb; + } + .defi-value { + font-family: monospace; + font-weight: 600; + margin-left: auto; + white-space: nowrap; + } + /* ── Payment Actions (Buy/Swap/Withdraw) ── */ .payment-actions { display: grid; @@ -2440,14 +2740,18 @@ class FolkWalletViewer extends HTMLElement { } } + // Include DeFi positions in aggregate + grandTotal += this.defiTotalUSD; + if (grandTotal === 0 && totalTokens === 0) return ""; const walletCount = (this.passKeyEOA ? 1 : 0) + this.linkedWallets.length; + const defiLabel = this.defiTotalUSD > 0 ? ` (incl. ${this.formatUSD(String(this.defiTotalUSD))} DeFi)` : ""; return `
-
Total Portfolio
+
Total Portfolio${defiLabel}
${this.formatUSD(String(grandTotal))}
@@ -2468,15 +2772,13 @@ class FolkWalletViewer extends HTMLElement { private renderViewTabs(): string { 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" }, + { id: "balances", label: "Token Balances" }, + { id: "budget", label: "Budget Visualization" }, + { id: "flows", label: "Flows" }, ]; // Show viz tabs for Safe wallets, demo, or when CRDT tokens exist const showViz = this.walletType === "safe" || this.isDemo || (this.isAuthenticated && this.crdtBalances.length > 0); - const visibleTabs = showViz ? tabs : [tabs[0], tabs[1]]; + const visibleTabs = showViz ? tabs : [tabs[0]]; return `
@@ -2777,6 +3079,64 @@ class FolkWalletViewer extends HTMLElement { `; } + private renderDefiPositions(): string { + if (this.defiLoading) { + return `
Loading DeFi positions...
`; + } + if (this.defiPositions.length === 0) return ""; + + // Group by protocol + const byProtocol = new Map>(); + for (const p of this.defiPositions) { + const existing = byProtocol.get(p.protocol) || []; + existing.push(p); + byProtocol.set(p.protocol, existing); + } + + const TYPE_BADGES: Record = { + deposit: { label: "Deposit", color: "#4ade80" }, + loan: { label: "Loan", color: "#f87171" }, + staked: { label: "Staked", color: "#a78bfa" }, + locked: { label: "Locked", color: "#fbbf24" }, + reward: { label: "Reward", color: "#38bdf8" }, + }; + + let cards = ""; + for (const [protocol, positions] of byProtocol) { + const protoTotal = positions.reduce((s, p) => s + p.totalValueUSD, 0); + const rows = positions.map(p => { + const badge = TYPE_BADGES[p.type] || { label: p.type, color: "#888" }; + const tokenList = p.tokens.map(t => + `${this.esc(t.symbol)} ${t.amount.toFixed(4)} (${this.formatUSD(String(t.valueUSD))})` + ).join(""); + return `
+ ${badge.label} + ${this.esc(p.chain)} + ${tokenList || this.formatUSD(String(p.totalValueUSD))} + ${this.formatUSD(String(p.totalValueUSD))} +
`; + }).join(""); + + cards += ` +
+
+ ${this.esc(protocol)} + ${this.formatUSD(String(protoTotal))} +
+ ${rows} +
`; + } + + return ` +
+
+

DeFi Positions

+ ${this.formatUSD(String(this.defiTotalUSD))} +
+ ${cards} +
`; + } + private renderPaymentActions(): string { if (!this.isAuthenticated) return ""; @@ -2914,7 +3274,7 @@ class FolkWalletViewer extends HTMLElement { Local
` : ""; - const isVizView = this.activeView !== "balances" && this.activeView !== "yield"; + const isVizView = this.activeView !== "balances"; return `
@@ -2948,9 +3308,7 @@ class FolkWalletViewer extends HTMLElement { ${this.renderViewTabs()} ${this.activeView === "balances" - ? this.renderBalanceTable() + this.renderPaymentActions() - : this.activeView === "yield" - ? this.renderYieldTab() + ? this.renderBalanceTable() + this.renderDefiPositions() + this.renderPaymentActions() : `
@@ -3016,6 +3374,7 @@ class FolkWalletViewer extends HTMLElement {
${this.renderBalanceTable()} + ${this.renderDefiPositions()} ${this.renderTransactionTables()}
@@ -3023,11 +3382,6 @@ class FolkWalletViewer extends HTMLElement { } private renderVisualizerTab(): string { - // Yield view is standalone — skip wallet UI entirely - if (this.activeView === "yield") { - return `${this.renderYieldStandaloneHeader()}${this.renderYieldTab()}`; - } - const hasData = this.hasData(); const showFullAddressBar = !hasData || this.addressBarExpanded; @@ -3210,7 +3564,7 @@ class FolkWalletViewer extends HTMLElement { }); // Draw visualization if active - if (this.activeView !== "balances" && this.activeView !== "yield" && this.hasData()) { + if (this.activeView !== "balances" && this.hasData()) { requestAnimationFrame(() => this.drawActiveVisualization()); } } diff --git a/modules/rwallet/lib/defi-positions.ts b/modules/rwallet/lib/defi-positions.ts new file mode 100644 index 0000000..a398801 --- /dev/null +++ b/modules/rwallet/lib/defi-positions.ts @@ -0,0 +1,165 @@ +/** + * Zerion DeFi positions — fetches protocol positions (Aave, Uniswap, etc.) + * with 5-minute in-memory cache. Requires ZERION_API_KEY env var. + */ + +export interface DefiPosition { + protocol: string; + type: string; // "deposit", "loan", "staked", "locked", "reward" + chain: string; // chain name + chainId: string; + tokens: Array<{ symbol: string; amount: number; valueUSD: number }>; + totalValueUSD: number; +} + +interface CacheEntry { + positions: DefiPosition[]; + ts: number; +} + +const TTL = 5 * 60 * 1000; +const cache = new Map(); +const inFlight = new Map>(); + +// Zerion chain ID → our chain ID mapping +const ZERION_CHAIN_MAP: Record = { + ethereum: "1", + optimism: "10", + "gnosis-chain": "100", + "xdai": "100", + polygon: "137", + base: "8453", + arbitrum: "42161", + "binance-smart-chain": "56", + avalanche: "43114", + celo: "42220", + "zksync-era": "324", +}; + +function getApiKey(): string | null { + return process.env.ZERION_API_KEY || null; +} + +/** + * Fetch DeFi protocol positions for an address via Zerion API. + * Returns empty array if ZERION_API_KEY is not set. + */ +export async function getDefiPositions(address: string): Promise { + const apiKey = getApiKey(); + if (!apiKey) return []; + + const lower = address.toLowerCase(); + + // Check cache + const cached = cache.get(lower); + if (cached && Date.now() - cached.ts < TTL) return cached.positions; + + // Deduplicate concurrent requests + const pending = inFlight.get(lower); + if (pending) return pending; + + const promise = (async (): Promise => { + try { + const auth = btoa(`${apiKey}:`); + const url = `https://api.zerion.io/v1/wallets/${lower}/positions/?filter[positions]=only_complex¤cy=usd&filter[trash]=only_non_trash`; + const res = await fetch(url, { + headers: { + accept: "application/json", + authorization: `Basic ${auth}`, + }, + signal: AbortSignal.timeout(15000), + }); + + if (res.status === 429) { + console.warn("[defi-positions] Zerion rate limited"); + return []; + } + if (!res.ok) { + console.warn(`[defi-positions] Zerion API error: ${res.status}`); + return []; + } + + const data = await res.json() as { data?: any[] }; + const positions = (data.data || []).map(normalizePosition).filter(Boolean) as DefiPosition[]; + + cache.set(lower, { positions, ts: Date.now() }); + return positions; + } catch (e) { + console.warn("[defi-positions] Failed to fetch:", e); + return []; + } finally { + inFlight.delete(lower); + } + })(); + + inFlight.set(lower, promise); + return promise; +} + +function normalizePosition(item: any): DefiPosition | null { + try { + const attrs = item.attributes; + if (!attrs) return null; + + const protocol = attrs.protocol_id || attrs.protocol || "Unknown"; + const posType = attrs.position_type || "deposit"; + const chainRaw = attrs.chain || item.relationships?.chain?.data?.id || ""; + const chainId = ZERION_CHAIN_MAP[chainRaw] || ""; + + const tokens: DefiPosition["tokens"] = []; + let totalValueUSD = 0; + + // Fungible positions — may have a single fungible_info or multiple + if (attrs.fungible_info) { + const fi = attrs.fungible_info; + const amount = attrs.quantity?.float ?? 0; + const value = attrs.value ?? 0; + tokens.push({ + symbol: fi.symbol || "???", + amount, + valueUSD: value, + }); + totalValueUSD += value; + } + + // Interpretations — complex positions with sub-tokens + if (attrs.interpretations) { + for (const interp of attrs.interpretations) { + if (interp.tokens) { + for (const t of interp.tokens) { + const tVal = t.value ?? 0; + tokens.push({ + symbol: t.fungible_info?.symbol || t.symbol || "???", + amount: t.quantity?.float ?? 0, + valueUSD: tVal, + }); + totalValueUSD += tVal; + } + } + } + } + + // Fall back to top-level value if tokens didn't capture it + if (totalValueUSD === 0 && attrs.value) { + totalValueUSD = attrs.value; + } + + if (totalValueUSD < 0.01 && tokens.length === 0) return null; + + // Prettify protocol name + const prettyProtocol = protocol + .replace(/-/g, " ") + .replace(/\b\w/g, (c: string) => c.toUpperCase()); + + return { + protocol: prettyProtocol, + type: posType, + chain: chainRaw.replace(/-/g, " ").replace(/\b\w/g, (c: string) => c.toUpperCase()), + chainId, + tokens, + totalValueUSD, + }; + } catch { + return null; + } +} diff --git a/modules/rwallet/lib/price-feed.ts b/modules/rwallet/lib/price-feed.ts new file mode 100644 index 0000000..ee3f598 --- /dev/null +++ b/modules/rwallet/lib/price-feed.ts @@ -0,0 +1,201 @@ +/** + * CoinGecko price feed with 5-minute in-memory cache. + * Provides USD prices for native coins and ERC-20 tokens. + */ + +// CoinGecko chain ID → platform ID mapping +const CHAIN_PLATFORM: Record = { + "1": "ethereum", + "10": "optimistic-ethereum", + "100": "xdai", + "137": "polygon-pos", + "8453": "base", + "42161": "arbitrum-one", + "56": "binance-smart-chain", + "43114": "avalanche", + "42220": "celo", + "324": "zksync", +}; + +// CoinGecko native coin IDs per chain +const NATIVE_COIN_ID: Record = { + "1": "ethereum", + "10": "ethereum", + "100": "dai", + "137": "matic-network", + "8453": "ethereum", + "42161": "ethereum", + "56": "binancecoin", + "43114": "avalanche-2", + "42220": "celo", + "324": "ethereum", +}; + +interface CacheEntry { + prices: Map; // address (lowercase) → USD price + nativePrice: number; + ts: number; +} + +const TTL = 5 * 60 * 1000; // 5 minutes +const cache = new Map(); +const inFlight = new Map>(); + +async function cgFetch(url: string): Promise { + const res = await fetch(url, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(10000), + }); + if (res.status === 429) { + console.warn("[price-feed] CoinGecko rate limited, waiting 60s..."); + await new Promise((r) => setTimeout(r, 60000)); + const retry = await fetch(url, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(10000), + }); + if (!retry.ok) return null; + return retry.json(); + } + if (!res.ok) return null; + return res.json(); +} + +/** Fetch native coin price for a chain */ +export async function getNativePrice(chainId: string): Promise { + const entry = cache.get(chainId); + if (entry && Date.now() - entry.ts < TTL) return entry.nativePrice; + + const coinId = NATIVE_COIN_ID[chainId]; + if (!coinId) return 0; + + const data = await cgFetch( + `https://api.coingecko.com/api/v3/simple/price?ids=${coinId}&vs_currencies=usd`, + ); + return data?.[coinId]?.usd ?? 0; +} + +/** Fetch token prices for a batch of contract addresses on a chain */ +export async function getTokenPrices( + chainId: string, + addresses: string[], +): Promise> { + const platform = CHAIN_PLATFORM[chainId]; + if (!platform || addresses.length === 0) return new Map(); + + const lower = addresses.map((a) => a.toLowerCase()); + const data = await cgFetch( + `https://api.coingecko.com/api/v3/simple/token_price/${platform}?contract_addresses=${lower.join(",")}&vs_currencies=usd`, + ); + + const result = new Map(); + if (data) { + for (const addr of lower) { + if (data[addr]?.usd) result.set(addr, data[addr].usd); + } + } + return result; +} + +/** Fetch and cache all prices for a chain (native + tokens) */ +async function fetchChainPrices( + chainId: string, + tokenAddresses: string[], +): Promise { + const existing = cache.get(chainId); + if (existing && Date.now() - existing.ts < TTL) return existing; + + // Deduplicate concurrent requests for same chain + const key = chainId; + const pending = inFlight.get(key); + if (pending) return pending; + + const promise = (async (): Promise => { + try { + const [nativePrice, tokenPrices] = await Promise.all([ + getNativePrice(chainId), + getTokenPrices(chainId, tokenAddresses), + ]); + const entry: CacheEntry = { + prices: tokenPrices, + nativePrice, + ts: Date.now(), + }; + cache.set(chainId, entry); + return entry; + } finally { + inFlight.delete(key); + } + })(); + + inFlight.set(key, promise); + return promise; +} + +interface BalanceItem { + tokenAddress: string | null; + token: { name: string; symbol: string; decimals: number }; + balance: string; + fiatBalance: string; + fiatConversion: string; +} + +/** + * Enrich balance items with USD prices from CoinGecko. + * Fills in fiatBalance and fiatConversion for items that have "0" values. + */ +export async function enrichWithPrices( + balances: BalanceItem[], + chainId: string, +): Promise { + // Skip testnets and unsupported chains + if (!CHAIN_PLATFORM[chainId] && !NATIVE_COIN_ID[chainId]) return balances; + + // Check if any balance actually needs pricing + const needsPricing = balances.some( + (b) => + BigInt(b.balance || "0") > 0n && + (b.fiatBalance === "0" || b.fiatBalance === "" || !b.fiatBalance), + ); + if (!needsPricing) return balances; + + const tokenAddresses = balances + .filter((b) => b.tokenAddress && b.tokenAddress !== "0x0000000000000000000000000000000000000000") + .map((b) => b.tokenAddress!); + + try { + const priceData = await fetchChainPrices(chainId, tokenAddresses); + + return balances.map((b) => { + // Skip if already has a real fiat value + if (b.fiatBalance && b.fiatBalance !== "0" && parseFloat(b.fiatBalance) > 0) { + return b; + } + + const balWei = BigInt(b.balance || "0"); + if (balWei === 0n) return b; + + let price = 0; + if (!b.tokenAddress || b.tokenAddress === "0x0000000000000000000000000000000000000000") { + // Native token + price = priceData.nativePrice; + } else { + price = priceData.prices.get(b.tokenAddress.toLowerCase()) ?? 0; + } + + if (price === 0) return b; + + const decimals = b.token?.decimals ?? 18; + const balHuman = Number(balWei) / Math.pow(10, decimals); + const fiatValue = balHuman * price; + + return { + ...b, + fiatConversion: String(price), + fiatBalance: String(fiatValue), + }; + }); + } catch (e) { + console.warn(`[price-feed] Failed to enrich prices for chain ${chainId}:`, e); + return balances; + } +} diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index bec4af3..b0217ff 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -11,6 +11,9 @@ import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { renderLanding } from "./landing"; import { verifyToken, extractToken } from "../../server/auth"; +import { enrichWithPrices } from "./lib/price-feed"; +import { getDefiPositions } from "./lib/defi-positions"; +import type { DefiPosition } from "./lib/defi-positions"; const routes = new Hono(); @@ -44,8 +47,9 @@ routes.get("/api/safe/:chainId/:address/balances", async (c) => { fiatBalance: item.fiatBalance || "0", fiatConversion: item.fiatConversion || "0", })); + const enriched = await enrichWithPrices(data, chainId); c.header("Cache-Control", "public, max-age=30"); - return c.json(data); + return c.json(enriched); }); // ── Fetch with exponential backoff (retry on 429) ── @@ -595,8 +599,9 @@ routes.get("/api/eoa/:chainId/:address/balances", async (c) => { await Promise.allSettled(promises); + const enriched = await enrichWithPrices(balances, chainId); c.header("Cache-Control", "public, max-age=30"); - return c.json(balances); + return c.json(enriched); }); // ── All-chains balance endpoints (fan out to every chain in parallel) ── @@ -655,7 +660,8 @@ routes.get("/api/eoa/:address/all-balances", async (c) => { await Promise.allSettled(tokenPromises); if (chainBalances.length > 0) { - results.push({ chainId, chainName: info.name, balances: chainBalances }); + const enriched = await enrichWithPrices(chainBalances, chainId); + results.push({ chainId, chainName: info.name, balances: enriched }); } }) ); @@ -693,7 +699,8 @@ routes.get("/api/safe/:address/all-balances", async (c) => { })).filter((b: BalanceItem) => BigInt(b.balance || "0") > 0n); if (chainBalances.length > 0) { - results.push({ chainId, chainName: info.name, balances: chainBalances }); + const enriched = await enrichWithPrices(chainBalances, chainId); + results.push({ chainId, chainName: info.name, balances: enriched }); } } catch {} }) @@ -712,6 +719,18 @@ interface BalanceItem { fiatConversion: string; } +// ── DeFi protocol positions via Zerion ── +routes.get("/api/defi/:address/positions", async (c) => { + const address = validateAddress(c); + if (!address) return c.json({ error: "Invalid Ethereum address" }, 400); + + const positions = await getDefiPositions(address); + const totalUSD = positions.reduce((sum, p) => sum + p.totalValueUSD, 0); + + c.header("Cache-Control", "public, max-age=300"); + return c.json({ positions, totalUSD }); +}); + // ── Safe owner addition proposal (add EncryptID EOA as signer) ── routes.post("/api/safe/:chainId/:address/add-owner-proposal", async (c) => { const claims = await verifyWalletAuth(c); @@ -1239,17 +1258,16 @@ function renderWallet(spaceSlug: string, initialView?: string) { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, }); } -routes.get("/wallets", (c) => c.html(renderWallet(c.req.param("space") || "demo"))); +routes.get("/wallets", (c) => c.html(renderWallet(c.req.param("space") || "demo", "budget"))); 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("/transactions", (c) => c.html(renderWallet(c.req.param("space") || "demo", "budget"))); -routes.get("/", (c) => c.html(renderWallet(c.req.param("space") || "demo"))); +routes.get("/", (c) => c.html(renderWallet(c.req.param("space") || "demo", "budget"))); export const walletModule: RSpaceModule = { id: "rwallet", @@ -1277,8 +1295,7 @@ export const walletModule: RSpaceModule = { acceptsFeeds: ["economic", "governance"], outputPaths: [ { path: "wallets", name: "Wallets", icon: "💳", description: "Connected Safe wallets and EOA accounts" }, - { path: "tokens", name: "Tokens", icon: "🪙", description: "Token balances across chains" }, + { path: "tokens", name: "Token Balances", 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" }, ], }; From c7c6b6a13bf562804481c0ffb4b5068c1a17c6df Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 14:04:00 -0700 Subject: [PATCH 2/2] chore(rwallet): bump JS cache version to v=19 Co-Authored-By: Claude Opus 4.6 --- modules/rwallet/mod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index b0217ff..c72b684 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -1258,7 +1258,7 @@ function renderWallet(spaceSlug: string, initialView?: string) { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, }); }