diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index 884c168..b769fac 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -6,8 +6,8 @@ * via EIP-6963 + SIWE. */ -import { transformToTimelineData, transformToSankeyData, transformToMultichainData } from "../lib/data-transform"; -import type { TimelineEntry, SankeyData, MultichainData } from "../lib/data-transform"; +import { transformToTimelineData, transformToSankeyData, transformToMultichainData, explorerLink, txExplorerLink } from "../lib/data-transform"; +import type { TimelineEntry, SankeyData, MultichainData, TransferRecord } from "../lib/data-transform"; import { loadD3, renderTimeline, renderFlowChart, renderSankey } from "../lib/wallet-viz"; import { DEMO_TIMELINE_DATA, DEMO_SANKEY_DATA, DEMO_MULTICHAIN_DATA } from "../lib/wallet-demo-data"; import { TourEngine } from "../../../shared/tour-engine"; @@ -70,6 +70,8 @@ const CHAIN_COLORS: Record = { "97": "#fbbf24", }; +const COLORS_TX = { cyan: "#00d4ff", green: "#4ade80", red: "#f87171" }; + const CHAIN_NAMES: Record = { "1": "Ethereum", "10": "Optimism", "100": "Gnosis", "137": "Polygon", "8453": "Base", "42161": "Arbitrum", "42220": "Celo", "43114": "Avalanche", @@ -719,7 +721,7 @@ class FolkWalletViewer extends HTMLElement { await Promise.allSettled( this.detectedChains.map(async (ch) => { try { - const res = await fetch(`${base}/api/safe/${ch.chainId}/${this.address}/transfers?limit=200`); + const res = await fetch(`${base}/api/safe/${ch.chainId}/${this.address}/transfers?limit=2000`); if (!res.ok) return; const data = await res.json(); // Parse results into incoming/outgoing @@ -827,6 +829,47 @@ class FolkWalletViewer extends HTMLElement { } } + private renderTransactionTables(): string { + const mc = this.vizData.multichain; + if (!mc || (!mc.allTransfers.incoming.length && !mc.allTransfers.outgoing.length)) return ""; + + const renderTable = (transfers: TransferRecord[], direction: "in" | "out") => { + if (!transfers.length) return "

None

"; + const rows = transfers.slice(0, 500).map((tx) => { + const date = tx.date ? new Date(tx.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "2-digit" }) : "—"; + const addr = direction === "in" ? (tx.from || "—") : (tx.to || "—"); + const addrShort = direction === "in" ? (tx.fromShort || "—") : (tx.toShort || "—"); + const usd = tx.usd > 0 ? `$${tx.usd >= 1000 ? Math.round(tx.usd).toLocaleString() : tx.usd.toFixed(2)}` : "—"; + const amount = tx.amount >= 1000 ? Math.round(tx.amount).toLocaleString() : tx.amount.toFixed(tx.amount < 1 ? 4 : 2); + const explorer = addr !== "—" ? explorerLink(addr, tx.chainId) : "#"; + return ` + ${date} + ${tx.chainName || tx.chainId} + ${addrShort} + ${tx.token} + ${amount} + ${usd} + `; + }).join(""); + return `
+ + ${rows} +
DateChain${direction === "in" ? "From" : "To"}TokenAmountUSD
`; + }; + + return ` +
+
+ Incoming (${mc.allTransfers.incoming.length}) + ${renderTable(mc.allTransfers.incoming, "in")} +
+
+ Outgoing (${mc.allTransfers.outgoing.length}) + ${renderTable(mc.allTransfers.outgoing, "out")} +
+
`; + } + private formatBalance(balance: string, decimals: number): string { const val = Number(balance) / Math.pow(10, decimals); if (val >= 1000000) return `${(val / 1000000).toFixed(2)}M`; @@ -1334,6 +1377,18 @@ class FolkWalletViewer extends HTMLElement { /* ── Viz container ── */ .viz-container { min-height: 200px; } + /* ── 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; } + .tx-section summary { padding: 12px 16px; cursor: pointer; font-weight: 600; font-size: 0.9rem; color: var(--rs-text-primary, #e0e0e0); user-select: none; } + .tx-section summary:hover { color: #00d4ff; } + .tx-table-wrap { overflow-x: auto; max-height: 400px; overflow-y: auto; } + .tx-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; } + .tx-table th { position: sticky; top: 0; background: var(--rs-bg-surface, #1a1a2e); padding: 8px 10px; text-align: left; color: #888; font-size: 0.7rem; text-transform: uppercase; border-bottom: 1px solid rgba(255,255,255,0.1); } + .tx-table td { padding: 6px 10px; border-bottom: 1px solid rgba(255,255,255,0.05); color: #ccc; white-space: nowrap; } + .tx-table tr:hover td { background: rgba(255,255,255,0.03); } + .chain-dot-sm { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; } + /* ── Dashboard: balance table ── */ .balance-table { width: 100%; border-collapse: collapse; } .balance-table th { @@ -2280,7 +2335,8 @@ class FolkWalletViewer extends HTMLElement { ? this.renderYieldTab() : `
${this.transfersLoading ? '
Loading transfer data...
' : ""} -
` + + ${this.renderTransactionTables()}` }`; } diff --git a/modules/rwallet/lib/wallet-viz.ts b/modules/rwallet/lib/wallet-viz.ts index 9763822..682d243 100644 --- a/modules/rwallet/lib/wallet-viz.ts +++ b/modules/rwallet/lib/wallet-viz.ts @@ -58,6 +58,27 @@ function nextVizId(): string { return `wv${++vizIdCounter}`; } +// ── Reset View button (shared across all 3 viz) ── + +function addResetButton(parent: HTMLElement, svg: any, zoom: any): void { + const btn = document.createElement("button"); + btn.textContent = "Reset View"; + Object.assign(btn.style, { + position: "absolute", top: "12px", right: "12px", zIndex: "10", + padding: "5px 12px", fontSize: "0.75rem", fontWeight: "600", + background: "rgba(255,255,255,0.08)", color: "#ccc", + border: "1px solid rgba(255,255,255,0.15)", borderRadius: "6px", + cursor: "pointer", backdropFilter: "blur(4px)", + }); + btn.addEventListener("mouseenter", () => { btn.style.background = "rgba(255,255,255,0.15)"; }); + btn.addEventListener("mouseleave", () => { btn.style.background = "rgba(255,255,255,0.08)"; }); + btn.addEventListener("click", () => { + svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity); + }); + parent.style.position = "relative"; + parent.appendChild(btn); +} + // ── Timeline (Balance River) ── export interface TimelineOptions { @@ -396,6 +417,7 @@ export function renderTimeline( }, { passive: false }); svg.call(zoom); + addResetButton(svgContainer, svg, zoom); } // ── Flow Chart (Multi-Chain Force-Directed) ── @@ -522,6 +544,7 @@ export function renderFlowChart( .on("start", () => svg.style("cursor", "grabbing")) .on("end", () => svg.style("cursor", "grab")); svg.call(flowZoom); + addResetButton(chartDiv, svg, flowZoom); } // ── Sankey Diagram ── @@ -625,4 +648,5 @@ export function renderSankey( .on("start", () => svg.style("cursor", "grabbing")) .on("end", () => svg.style("cursor", "grab")); svg.call(sankeyZoom); + addResetButton(chartDiv, svg, sankeyZoom); } diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index 5046b6c..e9b8ec3 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -48,6 +48,18 @@ routes.get("/api/safe/:chainId/:address/balances", async (c) => { return c.json(data); }); +// ── Fetch with exponential backoff (retry on 429) ── +async function fetchWithBackoff(url: string, maxRetries = 5): Promise { + let delay = 2000; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const res = await fetch(url, { signal: AbortSignal.timeout(15000) }); + if (res.status !== 429 || attempt === maxRetries) return res; + await new Promise((r) => setTimeout(r, delay)); + delay = Math.min(delay * 2, 32000); + } + throw new Error("Max retries exceeded"); +} + routes.get("/api/safe/:chainId/:address/transfers", async (c) => { const chainId = c.req.param("chainId"); const address = validateAddress(c); @@ -56,10 +68,25 @@ routes.get("/api/safe/:chainId/:address/transfers", async (c) => { if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); const rawLimit = parseInt(c.req.query("limit") || "100", 10); - const limit = isNaN(rawLimit) || rawLimit < 1 ? 100 : Math.min(rawLimit, 200); - const res = await fetch(`${safeApiBase(chainPrefix)}/safes/${address}/all-transactions/?limit=${limit}&executed=true`); - if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any); - return c.json(await res.json()); + const maxResults = isNaN(rawLimit) || rawLimit < 1 ? 100 : Math.min(rawLimit, 3000); + const pageSize = Math.min(maxResults, 100); + + const allResults: any[] = []; + let nextUrl: string | null = `${safeApiBase(chainPrefix)}/safes/${address}/all-transactions/?limit=${pageSize}&executed=true`; + + while (nextUrl && allResults.length < maxResults) { + const res = await fetchWithBackoff(nextUrl); + if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any); + const data = await res.json() as { results?: any[]; next?: string | null }; + if (data.results) allResults.push(...data.results); + nextUrl = data.next || null; + if (nextUrl && allResults.length < maxResults) { + await new Promise((r) => setTimeout(r, 400)); // polite delay + } + } + + c.header("Cache-Control", "public, max-age=60"); + return c.json({ count: allResults.length, results: allResults.slice(0, maxResults) }); }); routes.get("/api/safe/:chainId/:address/info", async (c) => { @@ -1014,7 +1041,7 @@ function renderWallet(spaceSlug: string, initialView?: string) { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, }); }