Merge branch 'dev'
This commit is contained in:
commit
6c07e277f5
|
|
@ -88,7 +88,7 @@ const EXAMPLE_WALLETS = [
|
||||||
{ name: "Vitalik.eth", address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", type: "EOA" },
|
{ name: "Vitalik.eth", address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", type: "EOA" },
|
||||||
];
|
];
|
||||||
|
|
||||||
type ViewTab = "balances" | "timeline" | "flow" | "sankey" | "yield";
|
type ViewTab = "balances" | "budget" | "flows";
|
||||||
|
|
||||||
interface YieldRate {
|
interface YieldRate {
|
||||||
protocol: string;
|
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 crdtBalances: Array<{ tokenId: string; name: string; symbol: string; decimals: number; icon: string; color: string; balance: number }> = [];
|
||||||
private crdtLoading = false;
|
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 myWalletBalances: Map<string, Array<{ chainId: string; chainName: string; balances: BalanceItem[] }>> = new Map();
|
||||||
private myWalletsLoading = false;
|
private myWalletsLoading = false;
|
||||||
|
|
||||||
|
|
@ -188,7 +193,7 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
private detailsModalOpen = false;
|
private detailsModalOpen = false;
|
||||||
|
|
||||||
// Visualization state
|
// Visualization state
|
||||||
private activeView: ViewTab = "balances";
|
private activeView: ViewTab = "budget";
|
||||||
private transfers: Map<string, any> | null = null;
|
private transfers: Map<string, any> | null = null;
|
||||||
private transfersLoading = false;
|
private transfersLoading = false;
|
||||||
private d3Ready = false;
|
private d3Ready = false;
|
||||||
|
|
@ -198,6 +203,10 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
multichain?: MultichainData;
|
multichain?: MultichainData;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
|
// Flows scrubber state
|
||||||
|
private flowsScrubberPos = -1; // -1 = show all
|
||||||
|
private flowsPlayInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
// Guided tour
|
// Guided tour
|
||||||
private _tour!: TourEngine;
|
private _tour!: TourEngine;
|
||||||
private static readonly TOUR_STEPS = [
|
private static readonly TOUR_STEPS = [
|
||||||
|
|
@ -227,7 +236,7 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
// Read initial-view attribute from server route
|
// Read initial-view attribute from server route
|
||||||
const initialView = this.getAttribute("initial-view");
|
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;
|
this.activeView = initialView as ViewTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,18 +249,13 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
this.checkAuthState();
|
this.checkAuthState();
|
||||||
this.initWalletSync(space);
|
this.initWalletSync(space);
|
||||||
|
|
||||||
if (this.activeView === "yield") {
|
// Auto-load address from passkey or linked wallet
|
||||||
this.render();
|
if (!this.address && this.passKeyEOA) {
|
||||||
this.loadYieldData();
|
this.address = this.passKeyEOA;
|
||||||
} 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
if (this.address) this.detectChains();
|
||||||
}
|
}
|
||||||
if (!localStorage.getItem("rwallet_tour_done")) {
|
if (!localStorage.getItem("rwallet_tour_done")) {
|
||||||
setTimeout(() => this._tour.start(), 1200);
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
|
@ -261,6 +265,10 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
this._stopPresence?.();
|
this._stopPresence?.();
|
||||||
|
if (this.flowsPlayInterval) {
|
||||||
|
clearInterval(this.flowsPlayInterval);
|
||||||
|
this.flowsPlayInterval = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkAuthState() {
|
private checkAuthState() {
|
||||||
|
|
@ -377,6 +385,24 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
this.render();
|
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 ──
|
// ── Yield data loading ──
|
||||||
|
|
||||||
private async loadYieldData() {
|
private async loadYieldData() {
|
||||||
|
|
@ -588,10 +614,6 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
tvl: r.tvl,
|
tvl: r.tvl,
|
||||||
vaultName: r.vaultName,
|
vaultName: r.vaultName,
|
||||||
}));
|
}));
|
||||||
if (this.activeView === "yield") {
|
|
||||||
this.sandboxActive = true;
|
|
||||||
this.recomputeSandbox();
|
|
||||||
}
|
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -650,11 +672,18 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.addressBarExpanded = 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()) {
|
if (this.hasData()) {
|
||||||
const showViz = this.walletType === "safe" || this.isDemo || (this.isAuthenticated && this.crdtBalances.length > 0);
|
const showViz = this.walletType === "safe" || this.isDemo || (this.isAuthenticated && this.crdtBalances.length > 0);
|
||||||
if (showViz) {
|
if (showViz && this.activeView !== "balances") {
|
||||||
this.activeView = "timeline";
|
if (this.activeView !== "budget" && this.activeView !== "flows") {
|
||||||
|
this.activeView = "budget";
|
||||||
|
}
|
||||||
this.render();
|
this.render();
|
||||||
this.loadTransfers();
|
this.loadTransfers();
|
||||||
return;
|
return;
|
||||||
|
|
@ -919,7 +948,7 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
chainColorMap["crdt"] = "#22c55e";
|
chainColorMap["crdt"] = "#22c55e";
|
||||||
|
|
||||||
switch (this.activeView) {
|
switch (this.activeView) {
|
||||||
case "timeline":
|
case "budget":
|
||||||
if (this.vizData.timeline && this.vizData.timeline.length > 0) {
|
if (this.vizData.timeline && this.vizData.timeline.length > 0) {
|
||||||
renderTimeline(container, this.vizData.timeline, { chainColors: chainColorMap });
|
renderTimeline(container, this.vizData.timeline, { chainColors: chainColorMap });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -927,28 +956,174 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "flow":
|
case "flows":
|
||||||
if (this.vizData.multichain) {
|
this.drawFlowsWithScrubber(container, chainColorMap);
|
||||||
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>';
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "sankey":
|
|
||||||
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>';
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 flow data available. Transfer data may still be loading.</div>';
|
||||||
|
}
|
||||||
|
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 {
|
private renderTransactionTables(): string {
|
||||||
const mc = this.vizData.multichain;
|
const mc = this.vizData.multichain;
|
||||||
if (!mc || (!mc.allTransfers.incoming.length && !mc.allTransfers.outgoing.length)) return "";
|
if (!mc || (!mc.allTransfers.incoming.length && !mc.allTransfers.outgoing.length)) return "";
|
||||||
|
|
@ -1061,15 +1236,13 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
if (this.activeView === view) return;
|
if (this.activeView === view) return;
|
||||||
this.activeView = view;
|
this.activeView = view;
|
||||||
|
|
||||||
if (view === "yield" && this.yieldRates.length === 0) {
|
if (view !== "balances" && !this.transfers && !this.isDemo) {
|
||||||
this.loadYieldData();
|
|
||||||
} else if (view !== "balances" && view !== "yield" && !this.transfers && !this.isDemo) {
|
|
||||||
this.loadTransfers();
|
this.loadTransfers();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.render();
|
this.render();
|
||||||
|
|
||||||
if (view !== "balances" && view !== "yield") {
|
if (view !== "balances") {
|
||||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
requestAnimationFrame(() => this.drawActiveVisualization());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1498,6 +1671,53 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
/* ── Viz container ── */
|
/* ── Viz container ── */
|
||||||
.viz-container { min-height: 200px; }
|
.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 ── */
|
/* ── Transaction tables ── */
|
||||||
.tx-tables { margin-top: 20px; }
|
.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; }
|
.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; }
|
.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 (Buy/Swap/Withdraw) ── */
|
||||||
.payment-actions {
|
.payment-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -2440,14 +2740,18 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include DeFi positions in aggregate
|
||||||
|
grandTotal += this.defiTotalUSD;
|
||||||
|
|
||||||
if (grandTotal === 0 && totalTokens === 0) return "";
|
if (grandTotal === 0 && totalTokens === 0) return "";
|
||||||
|
|
||||||
const walletCount = (this.passKeyEOA ? 1 : 0) + this.linkedWallets.length;
|
const walletCount = (this.passKeyEOA ? 1 : 0) + this.linkedWallets.length;
|
||||||
|
const defiLabel = this.defiTotalUSD > 0 ? ` (incl. ${this.formatUSD(String(this.defiTotalUSD))} DeFi)` : "";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="aggregate-stats">
|
<div class="aggregate-stats">
|
||||||
<div class="stat-card">
|
<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 class="stat-value">${this.formatUSD(String(grandTotal))}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
|
|
@ -2468,15 +2772,13 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
private renderViewTabs(): string {
|
private renderViewTabs(): string {
|
||||||
if (!this.hasData()) return "";
|
if (!this.hasData()) return "";
|
||||||
const tabs: { id: ViewTab; label: string }[] = [
|
const tabs: { id: ViewTab; label: string }[] = [
|
||||||
{ id: "balances", label: "Balances" },
|
{ id: "balances", label: "Token Balances" },
|
||||||
{ id: "yield", label: "Yield" },
|
{ id: "budget", label: "Budget Visualization" },
|
||||||
{ id: "timeline", label: "Timeline" },
|
{ id: "flows", label: "Flows" },
|
||||||
{ id: "flow", label: "Flow Map" },
|
|
||||||
{ id: "sankey", label: "Sankey" },
|
|
||||||
];
|
];
|
||||||
// Show viz tabs for Safe wallets, demo, or when CRDT tokens exist
|
// 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 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 `
|
return `
|
||||||
<div class="view-tabs">
|
<div class="view-tabs">
|
||||||
|
|
@ -2777,6 +3079,64 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
</table>`;
|
</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 {
|
private renderPaymentActions(): string {
|
||||||
if (!this.isAuthenticated) return "";
|
if (!this.isAuthenticated) return "";
|
||||||
|
|
||||||
|
|
@ -2914,7 +3274,7 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
Local
|
Local
|
||||||
</div>` : "";
|
</div>` : "";
|
||||||
|
|
||||||
const isVizView = this.activeView !== "balances" && this.activeView !== "yield";
|
const isVizView = this.activeView !== "balances";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="chains">
|
<div class="chains">
|
||||||
|
|
@ -2948,9 +3308,7 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
${this.renderViewTabs()}
|
${this.renderViewTabs()}
|
||||||
|
|
||||||
${this.activeView === "balances"
|
${this.activeView === "balances"
|
||||||
? this.renderBalanceTable() + this.renderPaymentActions()
|
? this.renderBalanceTable() + this.renderDefiPositions() + this.renderPaymentActions()
|
||||||
: this.activeView === "yield"
|
|
||||||
? this.renderYieldTab()
|
|
||||||
: `<div class="viz-wrapper">
|
: `<div class="viz-wrapper">
|
||||||
<button class="details-btn" data-action="open-details">Details</button>
|
<button class="details-btn" data-action="open-details">Details</button>
|
||||||
<div class="viz-container" id="viz-container">
|
<div class="viz-container" id="viz-container">
|
||||||
|
|
@ -3016,6 +3374,7 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
<div class="details-modal-body">
|
<div class="details-modal-body">
|
||||||
${this.renderBalanceTable()}
|
${this.renderBalanceTable()}
|
||||||
|
${this.renderDefiPositions()}
|
||||||
${this.renderTransactionTables()}
|
${this.renderTransactionTables()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3023,11 +3382,6 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderVisualizerTab(): string {
|
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 hasData = this.hasData();
|
||||||
const showFullAddressBar = !hasData || this.addressBarExpanded;
|
const showFullAddressBar = !hasData || this.addressBarExpanded;
|
||||||
|
|
||||||
|
|
@ -3210,7 +3564,7 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Draw visualization if active
|
// Draw visualization if active
|
||||||
if (this.activeView !== "balances" && this.activeView !== "yield" && this.hasData()) {
|
if (this.activeView !== "balances" && this.hasData()) {
|
||||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
requestAnimationFrame(() => this.drawActiveVisualization());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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¤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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,9 @@ import { getModuleInfoList } from "../../shared/module";
|
||||||
import type { RSpaceModule } from "../../shared/module";
|
import type { RSpaceModule } from "../../shared/module";
|
||||||
import { renderLanding } from "./landing";
|
import { renderLanding } from "./landing";
|
||||||
import { verifyToken, extractToken } from "../../server/auth";
|
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();
|
const routes = new Hono();
|
||||||
|
|
||||||
|
|
@ -44,8 +47,9 @@ routes.get("/api/safe/:chainId/:address/balances", async (c) => {
|
||||||
fiatBalance: item.fiatBalance || "0",
|
fiatBalance: item.fiatBalance || "0",
|
||||||
fiatConversion: item.fiatConversion || "0",
|
fiatConversion: item.fiatConversion || "0",
|
||||||
}));
|
}));
|
||||||
|
const enriched = await enrichWithPrices(data, chainId);
|
||||||
c.header("Cache-Control", "public, max-age=30");
|
c.header("Cache-Control", "public, max-age=30");
|
||||||
return c.json(data);
|
return c.json(enriched);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Fetch with exponential backoff (retry on 429) ──
|
// ── Fetch with exponential backoff (retry on 429) ──
|
||||||
|
|
@ -595,8 +599,9 @@ routes.get("/api/eoa/:chainId/:address/balances", async (c) => {
|
||||||
|
|
||||||
await Promise.allSettled(promises);
|
await Promise.allSettled(promises);
|
||||||
|
|
||||||
|
const enriched = await enrichWithPrices(balances, chainId);
|
||||||
c.header("Cache-Control", "public, max-age=30");
|
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) ──
|
// ── 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);
|
await Promise.allSettled(tokenPromises);
|
||||||
if (chainBalances.length > 0) {
|
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);
|
})).filter((b: BalanceItem) => BigInt(b.balance || "0") > 0n);
|
||||||
|
|
||||||
if (chainBalances.length > 0) {
|
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 {}
|
} catch {}
|
||||||
})
|
})
|
||||||
|
|
@ -712,6 +719,18 @@ interface BalanceItem {
|
||||||
fiatConversion: string;
|
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) ──
|
// ── Safe owner addition proposal (add EncryptID EOA as signer) ──
|
||||||
routes.post("/api/safe/:chainId/:address/add-owner-proposal", async (c) => {
|
routes.post("/api/safe/:chainId/:address/add-owner-proposal", async (c) => {
|
||||||
const claims = await verifyWalletAuth(c);
|
const claims = await verifyWalletAuth(c);
|
||||||
|
|
@ -1239,17 +1258,16 @@ function renderWallet(spaceSlug: string, initialView?: string) {
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
body: `<folk-wallet-viewer space="${spaceSlug}"${viewAttr}></folk-wallet-viewer>`,
|
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=19"></script>`,
|
||||||
styles: `<link rel="stylesheet" href="/modules/rwallet/wallet.css">`,
|
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("/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("/transactions", (c) => c.html(renderWallet(c.req.param("space") || "demo", "budget")));
|
||||||
routes.get("/yield", (c) => c.html(renderWallet(c.req.param("space") || "demo", "yield")));
|
|
||||||
|
|
||||||
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 = {
|
export const walletModule: RSpaceModule = {
|
||||||
id: "rwallet",
|
id: "rwallet",
|
||||||
|
|
@ -1277,8 +1295,7 @@ export const walletModule: RSpaceModule = {
|
||||||
acceptsFeeds: ["economic", "governance"],
|
acceptsFeeds: ["economic", "governance"],
|
||||||
outputPaths: [
|
outputPaths: [
|
||||||
{ path: "wallets", name: "Wallets", icon: "💳", description: "Connected Safe wallets and EOA accounts" },
|
{ 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: "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" },
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue