refactor(rwallet): consolidate tabs — remove Yield, merge viz into Budget + Flows

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-25 14:03:35 -07:00
parent 8071b620e1
commit 2bfd674d0e
4 changed files with 809 additions and 72 deletions

View File

@ -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<string, Array<{ chainId: string; chainName: string; balances: BalanceItem[] }>> = 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<string, any> | 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<typeof setInterval> | 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,10 +249,6 @@ 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;
@ -252,7 +257,6 @@ class FolkWalletViewer extends HTMLElement {
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,26 +956,172 @@ 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 = '<div class="empty">No flow data available.</div>';
}
case "flows":
this.drawFlowsWithScrubber(container, chainColorMap);
break;
}
}
case "sankey":
private drawFlowsWithScrubber(container: HTMLElement, chainColorMap: Record<string, string>) {
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 = '<div class="empty">No Sankey data available for the selected chain.</div>';
container.innerHTML = '<div class="empty">No flow data available. Transfer data may still be loading.</div>';
}
break;
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 = `
<div class="scrubber-row">
<button class="scrubber-btn" id="flows-play" title="Play/Pause"></button>
<input type="range" id="flows-range" min="-1" max="${timeline.length - 1}" value="${this.flowsScrubberPos}" class="scrubber-range">
<span class="scrubber-label" id="flows-date-label">${this.flowsScrubberPos < 0 ? "All transactions" : ""}</span>
</div>
<div class="scrubber-detail" id="flows-tx-detail"></div>
`;
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 = '<div class="empty" style="padding:40px;text-align:center;color:var(--rs-text-muted,#666);">Move the scrubber to include transactions.</div>';
}
// 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 = `
<span style="color:${color};font-weight:600;">${sign}$${usd}</span>
<span style="color:var(--rs-text-secondary,#888);margin:0 8px;">·</span>
<span>${tx.amount.toLocaleString(undefined, { maximumFractionDigits: 4 })} ${tx.token}</span>
<span style="color:var(--rs-text-secondary,#888);margin:0 8px;">·</span>
<span style="color:var(--rs-text-secondary,#888);">${peerLabel}: <span style="font-family:monospace;font-size:0.8em;">${peer.length > 14 ? peer.slice(0, 6) + "..." + peer.slice(-4) : peer}</span></span>
`;
}
};
// 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<string, number>();
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<string, typeof links[0]>();
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 {
@ -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 `
<div class="aggregate-stats">
<div class="stat-card">
<div class="stat-label">Total Portfolio</div>
<div class="stat-label">Total Portfolio${defiLabel}</div>
<div class="stat-value">${this.formatUSD(String(grandTotal))}</div>
</div>
<div class="stat-card">
@ -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 `
<div class="view-tabs">
@ -2777,6 +3079,64 @@ class FolkWalletViewer extends HTMLElement {
</table>`;
}
private renderDefiPositions(): string {
if (this.defiLoading) {
return `<div class="defi-section"><span class="spinner"></span> Loading DeFi positions...</div>`;
}
if (this.defiPositions.length === 0) return "";
// Group by protocol
const byProtocol = new Map<string, Array<typeof this.defiPositions[0]>>();
for (const p of this.defiPositions) {
const existing = byProtocol.get(p.protocol) || [];
existing.push(p);
byProtocol.set(p.protocol, existing);
}
const TYPE_BADGES: Record<string, { label: string; color: string }> = {
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 =>
`<span class="defi-token">${this.esc(t.symbol)} ${t.amount.toFixed(4)} (${this.formatUSD(String(t.valueUSD))})</span>`
).join("");
return `<div class="defi-position-row">
<span class="defi-type-badge" style="background:${badge.color}20;color:${badge.color};border:1px solid ${badge.color}40">${badge.label}</span>
<span class="defi-chain">${this.esc(p.chain)}</span>
<span class="defi-tokens">${tokenList || this.formatUSD(String(p.totalValueUSD))}</span>
<span class="defi-value">${this.formatUSD(String(p.totalValueUSD))}</span>
</div>`;
}).join("");
cards += `
<div class="defi-protocol-card">
<div class="defi-protocol-header">
<strong>${this.esc(protocol)}</strong>
<span class="defi-proto-total">${this.formatUSD(String(protoTotal))}</span>
</div>
${rows}
</div>`;
}
return `
<div class="defi-section">
<div class="defi-header">
<h3>DeFi Positions</h3>
<span class="defi-total">${this.formatUSD(String(this.defiTotalUSD))}</span>
</div>
${cards}
</div>`;
}
private renderPaymentActions(): string {
if (!this.isAuthenticated) return "";
@ -2914,7 +3274,7 @@ class FolkWalletViewer extends HTMLElement {
Local
</div>` : "";
const isVizView = this.activeView !== "balances" && this.activeView !== "yield";
const isVizView = this.activeView !== "balances";
return `
<div class="chains">
@ -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()
: `<div class="viz-wrapper">
<button class="details-btn" data-action="open-details">Details</button>
<div class="viz-container" id="viz-container">
@ -3016,6 +3374,7 @@ class FolkWalletViewer extends HTMLElement {
</div>
<div class="details-modal-body">
${this.renderBalanceTable()}
${this.renderDefiPositions()}
${this.renderTransactionTables()}
</div>
</div>
@ -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());
}
}

View File

@ -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<string, CacheEntry>();
const inFlight = new Map<string, Promise<DefiPosition[]>>();
// Zerion chain ID → our chain ID mapping
const ZERION_CHAIN_MAP: Record<string, string> = {
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<DefiPosition[]> {
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<DefiPosition[]> => {
try {
const auth = btoa(`${apiKey}:`);
const url = `https://api.zerion.io/v1/wallets/${lower}/positions/?filter[positions]=only_complex&currency=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;
}
}

View File

@ -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<string, string> = {
"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<string, string> = {
"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<string, number>; // address (lowercase) → USD price
nativePrice: number;
ts: number;
}
const TTL = 5 * 60 * 1000; // 5 minutes
const cache = new Map<string, CacheEntry>();
const inFlight = new Map<string, Promise<CacheEntry>>();
async function cgFetch(url: string): Promise<any> {
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<number> {
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<Map<string, number>> {
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<string, number>();
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<CacheEntry> {
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<CacheEntry> => {
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<BalanceItem[]> {
// 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;
}
}

View File

@ -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: `<folk-wallet-viewer space="${spaceSlug}"${viewAttr}></folk-wallet-viewer>`,
scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=17"></script>`,
scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=18"></script>`,
styles: `<link rel="stylesheet" href="/modules/rwallet/wallet.css">`,
});
}
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" },
],
};