feat(rwallet): pagination, transaction tables, and reset view buttons

Paginated transfers endpoint (up to 3000 txs with exponential backoff),
collapsible incoming/outgoing transaction tables below visualizations,
and Reset View buttons on all three D3 charts (Timeline, Flow, Sankey).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-20 16:27:06 -07:00
parent f9fc0ca6ec
commit 05459ec8a9
3 changed files with 116 additions and 9 deletions

View File

@ -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<string, string> = {
"97": "#fbbf24",
};
const COLORS_TX = { cyan: "#00d4ff", green: "#4ade80", red: "#f87171" };
const CHAIN_NAMES: Record<string, string> = {
"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 "<p style='color:#666;padding:12px;'>None</p>";
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 `<tr>
<td>${date}</td>
<td><span class="chain-dot-sm" style="background:var(--rs-accent,#14b8a6)"></span>${tx.chainName || tx.chainId}</td>
<td><a href="${explorer}" target="_blank" rel="noopener" title="${addr}" style="color:${COLORS_TX.cyan};text-decoration:none;font-family:monospace;font-size:0.8rem;">${addrShort}</a></td>
<td>${tx.token}</td>
<td style="text-align:right">${amount}</td>
<td style="text-align:right">${usd}</td>
</tr>`;
}).join("");
return `<div class="tx-table-wrap"><table class="tx-table">
<thead><tr><th>Date</th><th>Chain</th><th>${direction === "in" ? "From" : "To"}</th><th>Token</th><th style="text-align:right">Amount</th><th style="text-align:right">USD</th></tr></thead>
<tbody>${rows}</tbody>
</table></div>`;
};
return `
<div class="tx-tables">
<details class="tx-section" open>
<summary>Incoming (${mc.allTransfers.incoming.length})</summary>
${renderTable(mc.allTransfers.incoming, "in")}
</details>
<details class="tx-section">
<summary>Outgoing (${mc.allTransfers.outgoing.length})</summary>
${renderTable(mc.allTransfers.outgoing, "out")}
</details>
</div>`;
}
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()
: `<div class="viz-container" id="viz-container">
${this.transfersLoading ? '<div class="loading"><span class="spinner"></span> Loading transfer data...</div>' : ""}
</div>`
</div>
${this.renderTransactionTables()}`
}`;
}

View File

@ -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);
}

View File

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