Transform into generalized rWallet.online platform

Replace hardcoded single-wallet static site with a dynamic platform
that can explore any Safe multi-sig wallet across 7 chains via live
Safe Global API data.

New files:
- js/safe-api.js: Browser-side Safe Transaction Service API client
- js/data-transform.js: API response to D3 visualization transforms
- js/router.js: URL-based state management and shared address bar

Modified:
- index.html: Rich homepage with wallet input, ELI5, viz cards, demo CTA
- wallet-visualization.html: Dynamic single-chain Sankey from live data
- wallet-timeline-visualization.html: Dynamic Balance River from live data
- wallet-multichain-visualization.html: Dynamic multi-chain flow from live data
- Dockerfile: Copy js/ directory alongside HTML files
- docker-compose.yml: Add rwallet.online domain to Traefik routing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 03:32:54 -07:00
parent 995a54565a
commit d0c75aba8f
9 changed files with 2666 additions and 2002 deletions

View File

@ -2,6 +2,7 @@ FROM nginx:alpine
# Copy static files
COPY *.html /usr/share/nginx/html/
COPY js/ /usr/share/nginx/html/js/
# Custom nginx config for SPA-like behavior
RUN echo 'server { \

View File

@ -7,7 +7,7 @@ services:
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.rwallet.rule=Host(`wallets.bondingcurve.tech`)"
- "traefik.http.routers.rwallet.rule=Host(`rwallet.online`) || Host(`www.rwallet.online`) || Host(`wallets.bondingcurve.tech`)"
- "traefik.http.routers.rwallet.entrypoints=web"
- "traefik.http.services.rwallet.loadbalancer.server.port=80"
networks:

View File

@ -3,165 +3,806 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wallet Visualizations | wallets.bondingcurve.tech</title>
<title>rWallet.online | Democratic Wallet Management for Communities</title>
<meta name="description" content="Democratic wallet management for communities. Interactive visualizations for group treasury management — balance rivers, Sankey flow diagrams, and multi-chain analysis from live on-chain data.">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%);
color: #e0e0e0;
min-height: 100vh;
padding: 40px 20px;
:root {
--primary: #00d4ff;
--primary-dim: rgba(0, 212, 255, 0.15);
--accent: #7c3aed;
--accent-dim: rgba(124, 58, 237, 0.15);
--bg: #0f0f1a;
--bg-card: rgba(255, 255, 255, 0.03);
--border: rgba(255, 255, 255, 0.08);
--text: #e0e0e0;
--text-dim: #888;
--text-faint: #555;
--green: #34d399;
--blue: #3b82f6;
--purple: #a78bfa;
--orange: #fb923c;
--red: #f87171;
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
.container {
max-width: 1000px;
max-width: 1100px;
margin: 0 auto;
padding: 0 24px;
}
/* ─── Hero ─────────────────────────────────────────── */
.hero {
position: relative;
text-align: center;
padding: 80px 24px 60px;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse 600px 400px at 50% 20%, var(--primary-dim), transparent),
radial-gradient(ellipse 400px 300px at 80% 80%, var(--accent-dim), transparent);
z-index: 0;
}
.hero > * { position: relative; z-index: 1; }
.badge {
display: inline-block;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 5px 14px;
border-radius: 999px;
border: 1px solid rgba(0, 212, 255, 0.25);
background: var(--primary-dim);
color: var(--primary);
margin-bottom: 20px;
}
.hero h1 {
font-size: clamp(2rem, 5vw, 3.2rem);
font-weight: 800;
line-height: 1.15;
margin-bottom: 18px;
color: #fff;
}
.hero h1 span {
background: linear-gradient(135deg, var(--primary), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-sub {
font-size: 1.15rem;
color: var(--text-dim);
max-width: 620px;
margin: 0 auto 36px;
line-height: 1.65;
}
.hero-sub strong { color: var(--text); }
/* ─── Wallet Input ─────────────────────────────────── */
.wallet-input-section {
max-width: 680px;
margin: 0 auto;
}
h1 {
text-align: center;
margin-bottom: 10px;
color: #00d4ff;
font-size: 2.5rem;
.wallet-input-group {
display: flex;
gap: 0;
border-radius: 12px;
overflow: hidden;
border: 2px solid var(--border);
background: rgba(255, 255, 255, 0.04);
transition: border-color 0.3s;
}
.subtitle {
text-align: center;
color: #888;
margin-bottom: 50px;
font-size: 1.1rem;
.wallet-input-group:focus-within {
border-color: rgba(0, 212, 255, 0.5);
box-shadow: 0 0 30px rgba(0, 212, 255, 0.08);
}
.wallet-address {
text-align: center;
font-family: monospace;
color: #666;
margin-bottom: 40px;
font-size: 0.9rem;
.wallet-input-group input {
flex: 1;
padding: 16px 20px;
background: transparent;
border: none;
outline: none;
color: #fff;
font-size: 1rem;
font-family: 'Courier New', monospace;
letter-spacing: 0.02em;
}
.wallet-address a {
color: #00d4ff;
.wallet-input-group input::placeholder {
color: var(--text-faint);
font-family: 'Segoe UI', system-ui, sans-serif;
letter-spacing: 0;
}
.wallet-input-group button {
padding: 16px 28px;
background: linear-gradient(135deg, var(--primary), #0099cc);
border: none;
color: #fff;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
transition: opacity 0.2s;
white-space: nowrap;
}
.wallet-input-group button:hover {
opacity: 0.85;
}
.input-hint {
margin-top: 12px;
font-size: 0.82rem;
color: var(--text-faint);
text-align: center;
}
.input-hint a {
color: var(--primary);
text-decoration: none;
cursor: pointer;
}
.wallet-address a:hover {
.input-hint a:hover {
text-decoration: underline;
}
.cards {
.input-error {
color: var(--red);
font-size: 0.85rem;
margin-top: 8px;
text-align: center;
display: none;
}
/* ─── Section headings ─────────────────────────────── */
section {
padding: 60px 0;
}
.section-header {
text-align: center;
margin-bottom: 40px;
}
.section-header h2 {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 10px;
color: #fff;
}
.section-header p {
color: var(--text-dim);
font-size: 1.05rem;
max-width: 560px;
margin: 0 auto;
line-height: 1.6;
}
/* ─── ELI5 Cards ──────────────────────────────────── */
.eli5-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.eli5-card {
padding: 28px;
border-radius: 14px;
border: 2px solid;
}
.eli5-card.multi-chain {
border-color: rgba(59, 130, 246, 0.35);
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08), rgba(59, 130, 246, 0.02));
}
.eli5-card.transparent {
border-color: rgba(52, 211, 153, 0.35);
background: linear-gradient(135deg, rgba(52, 211, 153, 0.08), rgba(52, 211, 153, 0.02));
}
.eli5-card.visual {
border-color: rgba(167, 139, 250, 0.35);
background: linear-gradient(135deg, rgba(167, 139, 250, 0.08), rgba(167, 139, 250, 0.02));
}
.eli5-card .icon-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.eli5-card .icon-circle {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
color: #fff;
flex-shrink: 0;
}
.eli5-card.multi-chain .icon-circle { background: var(--blue); }
.eli5-card.transparent .icon-circle { background: var(--green); }
.eli5-card.visual .icon-circle { background: var(--purple); }
.eli5-card h3 {
font-size: 1.1rem;
font-weight: 700;
}
.eli5-card.multi-chain h3 { color: var(--blue); }
.eli5-card.transparent h3 { color: var(--green); }
.eli5-card.visual h3 { color: var(--purple); }
.eli5-card p {
font-size: 0.9rem;
color: var(--text-dim);
line-height: 1.6;
}
.eli5-card p strong {
display: block;
margin-top: 8px;
color: var(--text);
}
/* ─── Visualization Cards ─────────────────────────── */
.viz-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
}
.card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.1);
.viz-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 30px;
padding: 32px;
transition: all 0.3s ease;
text-decoration: none;
color: inherit;
display: block;
display: flex;
flex-direction: column;
}
.card:hover {
background: rgba(255,255,255,0.06);
.viz-card:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(0, 212, 255, 0.3);
transform: translateY(-4px);
box-shadow: 0 10px 40px rgba(0, 212, 255, 0.1);
box-shadow: 0 12px 40px rgba(0, 212, 255, 0.08);
}
.card h2 {
color: #00d4ff;
margin-bottom: 12px;
font-size: 1.4rem;
}
.card p {
color: #888;
line-height: 1.6;
font-size: 0.95rem;
}
.card .icon {
.viz-card .viz-icon {
font-size: 2.5rem;
margin-bottom: 16px;
margin-bottom: 14px;
}
.card .features {
.viz-card h3 {
color: var(--primary);
font-size: 1.3rem;
margin-bottom: 10px;
}
.viz-card p {
color: var(--text-dim);
line-height: 1.6;
font-size: 0.92rem;
flex: 1;
}
.viz-card .feature-list {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(255,255,255,0.1);
}
.card .features li {
color: #666;
font-size: 0.85rem;
margin-bottom: 6px;
border-top: 1px solid var(--border);
list-style: none;
padding-left: 20px;
}
.viz-card .feature-list li {
color: var(--text-faint);
font-size: 0.83rem;
margin-bottom: 5px;
padding-left: 18px;
position: relative;
}
.card .features li::before {
content: "→";
.viz-card .feature-list li::before {
content: "\2192";
position: absolute;
left: 0;
color: #00d4ff;
color: var(--primary);
}
/* ─── Supported Chains ─────────────────────────────── */
.chains-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 16px;
}
.chain-pill {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 18px;
border-radius: 999px;
background: var(--bg-card);
border: 1px solid var(--border);
font-size: 0.88rem;
font-weight: 500;
color: var(--text-dim);
transition: border-color 0.2s, background 0.2s;
}
.chain-pill:hover {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.06);
}
.chain-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
/* ─── Demo CTA ─────────────────────────────────────── */
.demo-section {
position: relative;
border-radius: 20px;
border: 2px solid rgba(0, 212, 255, 0.2);
background: linear-gradient(135deg, var(--primary-dim), var(--accent-dim));
padding: 60px 40px;
text-align: center;
overflow: hidden;
}
.demo-section::before {
content: '';
position: absolute;
top: -60px;
right: -60px;
width: 200px;
height: 200px;
background: var(--primary-dim);
border-radius: 50%;
filter: blur(60px);
}
.demo-section::after {
content: '';
position: absolute;
bottom: -60px;
left: -60px;
width: 200px;
height: 200px;
background: var(--accent-dim);
border-radius: 50%;
filter: blur(60px);
}
.demo-section > * { position: relative; z-index: 1; }
.demo-section h2 {
font-size: 1.6rem;
color: #fff;
margin-bottom: 12px;
}
.demo-section p {
color: var(--text-dim);
font-size: 1.05rem;
max-width: 500px;
margin: 0 auto 28px;
line-height: 1.6;
}
.demo-section .demo-address {
font-family: 'Courier New', monospace;
font-size: 0.8rem;
color: var(--text-faint);
margin-top: 16px;
}
.btn-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 14px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 14px 28px;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
cursor: pointer;
border: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), #0099cc);
color: #fff;
}
.btn-primary:hover { opacity: 0.85; transform: translateY(-1px); }
.btn-outline {
background: transparent;
border: 1.5px solid rgba(0, 212, 255, 0.3);
color: var(--primary);
}
.btn-outline:hover {
background: var(--primary-dim);
border-color: rgba(0, 212, 255, 0.5);
}
/* ─── How It Works ─────────────────────────────────── */
.steps-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
max-width: 900px;
margin: 0 auto;
}
.step {
text-align: center;
padding: 24px;
}
.step-number {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary), var(--accent));
color: #fff;
font-weight: 700;
font-size: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 14px;
}
.step h3 {
font-size: 1.05rem;
margin-bottom: 8px;
color: #fff;
}
.step p {
font-size: 0.88rem;
color: var(--text-dim);
line-height: 1.55;
}
/* ─── Footer ──────────────────────────────────────── */
footer {
text-align: center;
margin-top: 60px;
color: #555;
font-size: 0.85rem;
padding: 40px 24px;
color: var(--text-faint);
font-size: 0.82rem;
border-top: 1px solid var(--border);
}
footer a {
color: #00d4ff;
color: var(--primary);
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
footer a:hover { text-decoration: underline; }
footer .footer-links {
display: flex;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 10px;
}
footer .footer-sep {
color: var(--border);
}
/* ─── Responsive ──────────────────────────────────── */
@media (max-width: 640px) {
.hero { padding: 50px 16px 40px; }
.wallet-input-group { flex-direction: column; }
.wallet-input-group input { text-align: center; }
.wallet-input-group button { padding: 14px; }
.demo-section { padding: 40px 20px; }
.btn-row { flex-direction: column; align-items: center; }
}
</style>
</head>
<body>
<div class="container">
<h1>Wallet Visualizations</h1>
<p class="subtitle">Interactive multi-chain Safe wallet analytics</p>
<p class="wallet-address">
Analyzing: <a href="https://app.safe.global/transactions/history?safe=gno:0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1" target="_blank">0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1</a>
<!-- Hero -->
<section class="hero">
<div class="badge">Part of the rSpace Ecosystem</div>
<h1>Democratic Wallet<br><span>Management for Communities</span></h1>
<p class="hero-sub">
Interactive visualizations for <strong>group treasury management</strong>.
Explore any Safe multi-sig wallet with balance rivers, Sankey flow diagrams,
and multi-chain analysis &mdash; all from <strong>live on-chain data</strong>.
</p>
<div class="cards">
<a href="wallet-timeline-visualization.html" class="card">
<div class="icon">🌊</div>
<h2>Balance River Timeline</h2>
<p>Watch funds flow through the wallet over time. The river thickness represents balance, with inflows (green→blue) and outflows (blue→red) showing money movement.</p>
<ul class="features">
<li>Scroll to zoom into time periods</li>
<li>Horizontal scroll/drag to pan</li>
<li>Hover river for balance at any point</li>
<li>Flow width = transaction size</li>
</ul>
</a>
<a href="wallet-multichain-visualization.html" class="card">
<div class="icon">🔗</div>
<h2>Multi-Chain Flow Analysis</h2>
<p>Sankey diagram showing fund flows across all chains. Filter by chain to see activity on Gnosis, Ethereum, Avalanche, Optimism, and Arbitrum.</p>
<ul class="features">
<li>Interactive chain filtering</li>
<li>Flow diagram with addresses</li>
<li>Transaction tables per chain</li>
<li>Stats breakdown by direction</li>
</ul>
</a>
<a href="wallet-visualization.html" class="card">
<div class="icon">📊</div>
<h2>Gnosis Chain Overview</h2>
<p>Original single-chain Sankey visualization focused on Gnosis chain transactions including WXDAI, TEC tokens, and other activity.</p>
<ul class="features">
<li>Simple Sankey flow diagram</li>
<li>Address-level breakdown</li>
<li>Gnosis chain focused</li>
<li>Transaction details on hover</li>
</ul>
</a>
<!-- Wallet Input -->
<div class="wallet-input-section">
<div class="wallet-input-group">
<input type="text" id="wallet-input" placeholder="Enter a Safe wallet address (0x...)"
spellcheck="false" autocomplete="off" />
<button id="explore-btn">Explore Wallet</button>
</div>
<p class="input-error" id="input-error">Please enter a valid Ethereum address (0x followed by 40 hex characters).</p>
<p class="input-hint">
Or try the demo:
<a id="demo-link" href="#">TEC Commons Fund on Gnosis</a>
</p>
</div>
</section>
<div class="container">
<!-- ELI5 -->
<section>
<div class="section-header">
<div class="badge" style="border-color: rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); color: var(--text-dim);">ELI5</div>
<h2>rWallet in 30 Seconds</h2>
<p>
A <strong style="color: var(--blue);">multi-chain</strong>,
<strong style="color: var(--green);">transparent</strong>,
<strong style="color: var(--purple);">visual</strong>
wallet explorer built for community treasuries.
</p>
</div>
<div class="eli5-grid">
<div class="eli5-card multi-chain">
<div class="icon-row">
<div class="icon-circle">&#x1f310;</div>
<h3>Multi-Chain</h3>
</div>
<p>
Automatically detects your Safe across Ethereum, Gnosis, Polygon, Base, Optimism, Arbitrum, and Avalanche.
<strong>See all activity in one place.</strong>
</p>
</div>
<div class="eli5-card transparent">
<div class="icon-row">
<div class="icon-circle">&#x1f50d;</div>
<h3>Transparent</h3>
</div>
<p>
Real-time data fetched directly from the Safe Transaction Service API. No intermediaries, nothing hidden.
<strong>Verify every transaction yourself.</strong>
</p>
</div>
<div class="eli5-card visual">
<div class="icon-row">
<div class="icon-circle">&#x1f4ca;</div>
<h3>Visual</h3>
</div>
<p>
Interactive D3.js visualizations: Balance River timelines, Sankey flow diagrams, and cross-chain analysis.
<strong>Understand flows at a glance.</strong>
</p>
</div>
</div>
</section>
<!-- How It Works -->
<section>
<div class="section-header">
<div class="badge">How It Works</div>
<h2>From Address to Insight</h2>
</div>
<div class="steps-row">
<div class="step">
<div class="step-number">1</div>
<h3>Enter Address</h3>
<p>Paste any Safe multi-sig wallet address. rWallet checks all supported chains in parallel.</p>
</div>
<div class="step">
<div class="step-number">2</div>
<h3>Fetch Live Data</h3>
<p>Transactions, balances, and transfers are pulled directly from the Safe Global API &mdash; no backend needed.</p>
</div>
<div class="step">
<div class="step-number">3</div>
<h3>Visualize</h3>
<p>Choose from three interactive visualization modes to understand fund flows, balances over time, and cross-chain activity.</p>
</div>
<div class="step">
<div class="step-number">4</div>
<h3>Share &amp; Verify</h3>
<p>Every view has a shareable deep-link. Anyone can verify the data independently &mdash; full transparency.</p>
</div>
</div>
</section>
<!-- Visualization Types -->
<section>
<div class="section-header">
<h2>Three Ways to Explore</h2>
<p>Each visualization reveals different aspects of your wallet's activity.</p>
</div>
<div class="viz-grid">
<a href="wallet-timeline-visualization.html" class="viz-card" id="viz-timeline">
<div class="viz-icon">&#x1f30a;</div>
<h3>Balance River Timeline</h3>
<p>Watch funds flow through the wallet over time. The river thickness represents balance, with inflows and outflows color-coded for instant comprehension.</p>
<ul class="feature-list">
<li>Scroll to zoom into time periods</li>
<li>Drag or shift-scroll to pan through history</li>
<li>Hover the river for balance at any point</li>
<li>Flow width proportional to transaction size</li>
</ul>
</a>
<a href="wallet-multichain-visualization.html" class="viz-card" id="viz-multichain">
<div class="viz-icon">&#x1f517;</div>
<h3>Multi-Chain Flow Analysis</h3>
<p>See fund flows across all detected chains in one view. Filter by chain to drill into Gnosis, Ethereum, Base, or any other active network.</p>
<ul class="feature-list">
<li>Auto-detects chains with Safe deployments</li>
<li>Interactive chain filtering</li>
<li>Flow diagram with address-level detail</li>
<li>Stats breakdown by direction</li>
</ul>
</a>
<a href="wallet-visualization.html" class="viz-card" id="viz-sankey">
<div class="viz-icon">&#x1f4ca;</div>
<h3>Single-Chain Sankey</h3>
<p>Classic Sankey diagram showing the complete flow of funds on a single chain. Nodes represent addresses, links represent value transferred.</p>
<ul class="feature-list">
<li>Per-chain Sankey flow diagram</li>
<li>Address-level fund flow breakdown</li>
<li>Chain selector for multi-chain wallets</li>
<li>Transaction details on hover</li>
</ul>
</a>
</div>
</section>
<!-- Supported Chains -->
<section>
<div class="section-header">
<h2>Supported Chains</h2>
<p>rWallet auto-detects Safe deployments across these networks.</p>
</div>
<div class="chains-row">
<div class="chain-pill">
<span class="chain-dot" style="background: #627eea;"></span>
Ethereum
</div>
<div class="chain-pill">
<span class="chain-dot" style="background: #ff0420;"></span>
Optimism
</div>
<div class="chain-pill">
<span class="chain-dot" style="background: #04795b;"></span>
Gnosis
</div>
<div class="chain-pill">
<span class="chain-dot" style="background: #8247e5;"></span>
Polygon
</div>
<div class="chain-pill">
<span class="chain-dot" style="background: #0052ff;"></span>
Base
</div>
<div class="chain-pill">
<span class="chain-dot" style="background: #28a0f0;"></span>
Arbitrum
</div>
<div class="chain-pill">
<span class="chain-dot" style="background: #e84142;"></span>
Avalanche
</div>
</div>
</section>
<!-- Demo CTA -->
<section>
<div class="demo-section">
<div class="badge">Live Demo</div>
<h2>See It in Action</h2>
<p>
Explore the TEC Commons Fund &mdash; a real multi-chain Safe wallet
managing community funds on Gnosis and beyond.
</p>
<div class="btn-row">
<a href="wallet-timeline-visualization.html?address=0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1" class="btn btn-primary">
&#x1f30a;&nbsp; Balance River
</a>
<a href="wallet-multichain-visualization.html?address=0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1" class="btn btn-outline">
&#x1f517;&nbsp; Multi-Chain Flow
</a>
<a href="wallet-visualization.html?address=0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1&chainId=100" class="btn btn-outline">
&#x1f4ca;&nbsp; Gnosis Sankey
</a>
</div>
<p class="demo-address">0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1</p>
</div>
</section>
<footer>
<p>Built with D3.js | Data from <a href="https://safe.global" target="_blank">Safe Global API</a></p>
</footer>
</div>
<!-- Footer -->
<footer>
<div class="footer-links">
<span>Built with <a href="https://d3js.org" target="_blank" rel="noopener">D3.js</a></span>
<span class="footer-sep">|</span>
<span>Data from <a href="https://safe.global" target="_blank" rel="noopener">Safe Global API</a></span>
<span class="footer-sep">|</span>
<span>Part of the <a href="https://rspace.online" target="_blank" rel="noopener">rSpace Ecosystem</a></span>
</div>
<p>No backend. No tracking. All data fetched client-side from public APIs.</p>
</footer>
<script>
// ─── Wallet Input Logic ────────────────────────────────────
const DEMO_ADDRESS = '0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1';
const input = document.getElementById('wallet-input');
const btn = document.getElementById('explore-btn');
const error = document.getElementById('input-error');
const demoLink = document.getElementById('demo-link');
function isValidAddress(addr) {
return /^0x[a-fA-F0-9]{40}$/.test(addr);
}
function navigateToWallet(address) {
// Default to multichain view for the best first experience
window.location.href = `wallet-multichain-visualization.html?address=${address}`;
}
function tryExplore() {
const addr = input.value.trim();
if (!isValidAddress(addr)) {
error.style.display = 'block';
input.style.borderColor = '#f87171';
setTimeout(() => {
error.style.display = 'none';
input.style.borderColor = '';
}, 3000);
return;
}
navigateToWallet(addr);
}
btn.addEventListener('click', tryExplore);
input.addEventListener('keydown', e => { if (e.key === 'Enter') tryExplore(); });
// Demo link
demoLink.addEventListener('click', e => {
e.preventDefault();
navigateToWallet(DEMO_ADDRESS);
});
// If viz cards don't have an address param yet, add the demo address on click
// (only if user hasn't entered their own)
document.querySelectorAll('.viz-card').forEach(card => {
card.addEventListener('click', e => {
const addr = input.value.trim();
if (isValidAddress(addr)) {
e.preventDefault();
const url = new URL(card.href, window.location.origin);
url.searchParams.set('address', addr);
window.location.href = url.toString();
}
// Otherwise, follow the link as-is (no address = page shows its own input)
});
});
</script>
</body>
</html>

635
js/data-transform.js Normal file
View File

@ -0,0 +1,635 @@
/**
* Data Transform Module for rWallet.online
* Converts Safe Global API responses into formats expected by D3 visualizations.
*/
const DataTransform = (() => {
// ─── Helpers ───────────────────────────────────────────────────
function shortenAddress(addr) {
if (!addr || addr.length < 10) return addr || 'Unknown';
return addr.slice(0, 6) + '...' + addr.slice(-4);
}
function explorerLink(address, chainId) {
const chain = SafeAPI.CHAINS[chainId];
if (!chain) return '#';
return `${chain.explorer}/address/${address}`;
}
function txExplorerLink(txHash, chainId) {
const chain = SafeAPI.CHAINS[chainId];
if (!chain) return '#';
return `${chain.explorer}/tx/${txHash}`;
}
/**
* Extract token value in human-readable form from a transfer object.
* Handles both ERC20 and native transfers.
*/
function getTransferValue(transfer) {
if (transfer.type === 'ERC20_TRANSFER' || transfer.transferType === 'ERC20_TRANSFER') {
const decimals = transfer.tokenInfo?.decimals || transfer.token?.decimals || 18;
const raw = transfer.value || '0';
return parseFloat(raw) / Math.pow(10, decimals);
}
if (transfer.type === 'ETHER_TRANSFER' || transfer.transferType === 'ETHER_TRANSFER') {
return parseFloat(transfer.value || '0') / 1e18;
}
return 0;
}
function getTokenSymbol(transfer) {
return transfer.tokenInfo?.symbol || transfer.token?.symbol || 'ETH';
}
function getTokenName(transfer) {
return transfer.tokenInfo?.name || transfer.token?.name || 'Native';
}
// ─── Stablecoin USD estimation ─────────────────────────────────
const STABLECOINS = new Set([
'USDC', 'USDT', 'DAI', 'WXDAI', 'BUSD', 'TUSD', 'USDP', 'FRAX',
'LUSD', 'GUSD', 'sUSD', 'USDD', 'USDGLO', 'USD+', 'USDe', 'crvUSD',
'GHO', 'PYUSD', 'DOLA', 'Yield-USD', 'yUSD',
]);
function estimateUSD(value, symbol) {
// Stablecoins ≈ $1
if (STABLECOINS.has(symbol)) return value;
// We can't price non-stablecoins without an oracle - return value as-is
// The visualization will show token amounts for non-stablecoins
return null;
}
// ─── Transform: Outgoing Multisig Transactions ─────────────────
/**
* Parse a multisig transaction's ERC20 transfers from dataDecoded.
* Returns array of { to, value, token, symbol, decimals }
*/
function parseMultisigTransfers(tx) {
const transfers = [];
// Direct ETH/native transfer
if (tx.value && tx.value !== '0') {
transfers.push({
to: tx.to,
value: parseFloat(tx.value) / 1e18,
token: null,
symbol: SafeAPI.CHAINS[tx.chainId]?.symbol || 'ETH',
usd: null,
});
}
// ERC20 transfer from decoded data
if (tx.dataDecoded) {
const method = tx.dataDecoded.method;
const params = tx.dataDecoded.parameters || [];
if (method === 'transfer') {
const to = params.find(p => p.name === 'to')?.value;
const rawValue = params.find(p => p.name === 'value')?.value || '0';
// We'll try to identify the token from the `to` contract address
// For now, use 18 decimals as default
const value = parseFloat(rawValue) / 1e18;
transfers.push({ to, value, token: tx.to, symbol: '???', usd: null });
}
// MultiSend (batched transactions)
if (method === 'multiSend') {
const txsParam = params.find(p => p.name === 'transactions');
if (txsParam && txsParam.valueDecoded) {
for (const innerTx of txsParam.valueDecoded) {
if (innerTx.value && innerTx.value !== '0') {
transfers.push({
to: innerTx.to,
value: parseFloat(innerTx.value) / 1e18,
token: null,
symbol: SafeAPI.CHAINS[tx.chainId]?.symbol || 'ETH',
usd: null,
});
}
if (innerTx.dataDecoded?.method === 'transfer') {
const to2 = innerTx.dataDecoded.parameters?.find(p => p.name === 'to')?.value;
const raw2 = innerTx.dataDecoded.parameters?.find(p => p.name === 'value')?.value || '0';
const val2 = parseFloat(raw2) / 1e18;
transfers.push({ to: to2, value: val2, token: innerTx.to, symbol: '???', usd: null });
}
}
}
}
}
return transfers;
}
// ─── Transform: Timeline Data (for Balance River) ──────────────
/**
* Transform incoming transfers + outgoing multisig txs into timeline format.
* Returns sorted array of { date, type, amount, token, usd, chain, from/to }
*/
function transformToTimelineData(chainDataMap, safeAddress) {
const timeline = [];
for (const [chainId, data] of chainDataMap) {
const chainName = SafeAPI.CHAINS[chainId]?.name.toLowerCase() || `chain-${chainId}`;
// Incoming transfers
if (data.incoming) {
for (const transfer of data.incoming) {
const value = getTransferValue(transfer);
const symbol = getTokenSymbol(transfer);
if (value <= 0) continue;
const usd = estimateUSD(value, symbol);
timeline.push({
date: transfer.executionDate || transfer.blockTimestamp || transfer.timestamp,
type: 'in',
amount: value,
token: symbol,
usd: usd !== null ? usd : value, // fallback to raw value
hasUsdEstimate: usd !== null,
chain: chainName,
chainId,
from: shortenAddress(transfer.from),
fromFull: transfer.from,
});
}
}
// Outgoing multisig transactions
if (data.outgoing) {
for (const tx of data.outgoing) {
if (!tx.isExecuted) continue;
// Parse transfers from the transaction
const txTransfers = [];
// Check transfers array if available
if (tx.transfers && tx.transfers.length > 0) {
for (const t of tx.transfers) {
if (t.from?.toLowerCase() === safeAddress.toLowerCase()) {
const value = getTransferValue(t);
const symbol = getTokenSymbol(t);
if (value > 0) {
txTransfers.push({
to: t.to,
value,
symbol,
usd: estimateUSD(value, symbol),
});
}
}
}
}
// Fallback: try parsing from dataDecoded or direct value
if (txTransfers.length === 0) {
// Direct ETH/native value
if (tx.value && tx.value !== '0') {
const val = parseFloat(tx.value) / 1e18;
const sym = SafeAPI.CHAINS[chainId]?.symbol || 'ETH';
txTransfers.push({ to: tx.to, value: val, symbol: sym, usd: estimateUSD(val, sym) });
}
// ERC20 from decoded data
if (tx.dataDecoded?.method === 'transfer') {
const params = tx.dataDecoded.parameters || [];
const to = params.find(p => p.name === 'to')?.value;
const rawVal = params.find(p => p.name === 'value')?.value || '0';
// Try to get token info from tokenAddress
const decimals = 18; // default
const val = parseFloat(rawVal) / Math.pow(10, decimals);
txTransfers.push({ to, value: val, symbol: 'Token', usd: null });
}
// MultiSend
if (tx.dataDecoded?.method === 'multiSend') {
const txsParam = tx.dataDecoded.parameters?.find(p => p.name === 'transactions');
if (txsParam?.valueDecoded) {
for (const inner of txsParam.valueDecoded) {
if (inner.value && inner.value !== '0') {
const val = parseFloat(inner.value) / 1e18;
const sym = SafeAPI.CHAINS[chainId]?.symbol || 'ETH';
txTransfers.push({ to: inner.to, value: val, symbol: sym, usd: estimateUSD(val, sym) });
}
if (inner.dataDecoded?.method === 'transfer') {
const to2 = inner.dataDecoded.parameters?.find(p => p.name === 'to')?.value;
const raw2 = inner.dataDecoded.parameters?.find(p => p.name === 'value')?.value || '0';
const val2 = parseFloat(raw2) / 1e18;
txTransfers.push({ to: to2, value: val2, symbol: 'Token', usd: null });
}
}
}
}
}
for (const t of txTransfers) {
const usd = t.usd !== null ? t.usd : t.value;
timeline.push({
date: tx.executionDate,
type: 'out',
amount: t.value,
token: t.symbol,
usd: usd,
hasUsdEstimate: t.usd !== null,
chain: chainName,
chainId,
to: shortenAddress(t.to),
toFull: t.to,
});
}
}
}
}
// Sort by date
return timeline
.filter(t => t.date)
.map(t => ({ ...t, date: new Date(t.date) }))
.sort((a, b) => a.date - b.date);
}
// ─── Transform: Sankey Data (for single-chain flow) ────────────
/**
* Build Sankey nodes & links from a single chain's data.
* Returns { nodes: [{name, type}], links: [{source, target, value, token}] }
*/
function transformToSankeyData(chainData, safeAddress) {
const nodeMap = new Map(); // address → index
const nodes = [];
const links = [];
const walletLabel = 'Safe Wallet';
function getNodeIndex(address, type) {
// For the safe wallet, always use the same key
const key = address.toLowerCase() === safeAddress.toLowerCase()
? 'wallet'
: `${type}:${address.toLowerCase()}`;
if (!nodeMap.has(key)) {
const idx = nodes.length;
nodeMap.set(key, idx);
const label = address.toLowerCase() === safeAddress.toLowerCase()
? walletLabel
: shortenAddress(address);
nodes.push({ name: label, type, address });
}
return nodeMap.get(key);
}
// Wallet node always first
getNodeIndex(safeAddress, 'wallet');
// Aggregate inflows by source address + token
const inflowAgg = new Map();
if (chainData.incoming) {
for (const transfer of chainData.incoming) {
const value = getTransferValue(transfer);
const symbol = getTokenSymbol(transfer);
if (value <= 0 || !transfer.from) continue;
const key = `${transfer.from.toLowerCase()}:${symbol}`;
const existing = inflowAgg.get(key) || { from: transfer.from, value: 0, symbol };
existing.value += value;
inflowAgg.set(key, existing);
}
}
// Add inflow links
for (const [, agg] of inflowAgg) {
const sourceIdx = getNodeIndex(agg.from, 'source');
const walletIdx = nodeMap.get('wallet');
links.push({
source: sourceIdx,
target: walletIdx,
value: agg.value,
token: agg.symbol,
});
}
// Aggregate outflows by target address + token
const outflowAgg = new Map();
if (chainData.outgoing) {
for (const tx of chainData.outgoing) {
if (!tx.isExecuted) continue;
// Direct value transfer
if (tx.value && tx.value !== '0' && tx.to) {
const val = parseFloat(tx.value) / 1e18;
const sym = SafeAPI.CHAINS[chainData.chainId]?.symbol || 'ETH';
const key = `${tx.to.toLowerCase()}:${sym}`;
const existing = outflowAgg.get(key) || { to: tx.to, value: 0, symbol: sym };
existing.value += val;
outflowAgg.set(key, existing);
}
// ERC20 transfer
if (tx.dataDecoded?.method === 'transfer') {
const params = tx.dataDecoded.parameters || [];
const to = params.find(p => p.name === 'to')?.value;
const rawVal = params.find(p => p.name === 'value')?.value || '0';
if (to) {
const val = parseFloat(rawVal) / 1e18;
const key = `${to.toLowerCase()}:Token`;
const existing = outflowAgg.get(key) || { to, value: 0, symbol: 'Token' };
existing.value += val;
outflowAgg.set(key, existing);
}
}
// MultiSend
if (tx.dataDecoded?.method === 'multiSend') {
const txsParam = tx.dataDecoded.parameters?.find(p => p.name === 'transactions');
if (txsParam?.valueDecoded) {
for (const inner of txsParam.valueDecoded) {
if (inner.value && inner.value !== '0' && inner.to) {
const val = parseFloat(inner.value) / 1e18;
const sym = SafeAPI.CHAINS[chainData.chainId]?.symbol || 'ETH';
const key = `${inner.to.toLowerCase()}:${sym}`;
const existing = outflowAgg.get(key) || { to: inner.to, value: 0, symbol: sym };
existing.value += val;
outflowAgg.set(key, existing);
}
if (inner.dataDecoded?.method === 'transfer') {
const to2 = inner.dataDecoded.parameters?.find(p => p.name === 'to')?.value;
const raw2 = inner.dataDecoded.parameters?.find(p => p.name === 'value')?.value || '0';
if (to2) {
const val2 = parseFloat(raw2) / 1e18;
const key = `${to2.toLowerCase()}:Token`;
const existing = outflowAgg.get(key) || { to: to2, value: 0, symbol: 'Token' };
existing.value += val2;
outflowAgg.set(key, existing);
}
}
}
}
}
}
}
// Add outflow links
const walletIdx = nodeMap.get('wallet');
for (const [, agg] of outflowAgg) {
const targetIdx = getNodeIndex(agg.to, 'target');
links.push({
source: walletIdx,
target: targetIdx,
value: agg.value,
token: agg.symbol,
});
}
// Filter out tiny values (noise)
const maxValue = Math.max(...links.map(l => l.value), 1);
const threshold = maxValue * 0.001; // 0.1% of max
const filteredLinks = links.filter(l => l.value >= threshold);
return { nodes, links: filteredLinks };
}
// ─── Transform: Multi-Chain Flow Data ──────────────────────────
/**
* Build multi-chain flow visualization data.
* Returns { chainStats, flowData, allTransfers }
*/
function transformToMultichainData(chainDataMap, safeAddress) {
const chainStats = {};
const flowData = {};
const allTransfers = { incoming: [], outgoing: [] };
let totalTransfers = 0;
let totalInflow = 0;
let totalOutflow = 0;
const allAddresses = new Set();
let minDate = null;
let maxDate = null;
for (const [chainId, data] of chainDataMap) {
const chainName = SafeAPI.CHAINS[chainId]?.name.toLowerCase() || `chain-${chainId}`;
let chainTransfers = 0;
let chainInflow = 0;
let chainOutflow = 0;
const chainAddresses = new Set();
let chainMinDate = null;
let chainMaxDate = null;
const flows = [];
// Incoming
const inflowAgg = new Map();
if (data.incoming) {
for (const transfer of data.incoming) {
const value = getTransferValue(transfer);
const symbol = getTokenSymbol(transfer);
if (value <= 0) continue;
const usd = estimateUSD(value, symbol);
const usdVal = usd !== null ? usd : value;
chainTransfers++;
chainInflow += usdVal;
if (transfer.from) {
chainAddresses.add(transfer.from.toLowerCase());
allAddresses.add(transfer.from.toLowerCase());
}
const date = transfer.executionDate || transfer.blockTimestamp;
if (date) {
const d = new Date(date);
if (!chainMinDate || d < chainMinDate) chainMinDate = d;
if (!chainMaxDate || d > chainMaxDate) chainMaxDate = d;
}
// Aggregate for flow diagram
const from = transfer.from || 'Unknown';
const key = `${shortenAddress(from)}`;
const existing = inflowAgg.get(key) || { from: shortenAddress(from), value: 0, token: symbol };
existing.value += usdVal;
inflowAgg.set(key, existing);
allTransfers.incoming.push({
chainId,
chainName,
date: date || '',
from: transfer.from,
fromShort: shortenAddress(transfer.from),
token: symbol,
amount: value,
usd: usdVal,
});
}
}
// Build flow entries from aggregated inflows
for (const [, agg] of inflowAgg) {
flows.push({
from: agg.from,
to: 'Safe Wallet',
value: Math.round(agg.value),
token: agg.token,
chain: chainName,
});
}
// Outgoing
const outflowAgg = new Map();
if (data.outgoing) {
for (const tx of data.outgoing) {
if (!tx.isExecuted) continue;
chainTransfers++;
const date = tx.executionDate;
if (date) {
const d = new Date(date);
if (!chainMinDate || d < chainMinDate) chainMinDate = d;
if (!chainMaxDate || d > chainMaxDate) chainMaxDate = d;
}
// Parse all transfers from the tx
const outTransfers = [];
if (tx.value && tx.value !== '0' && tx.to) {
const val = parseFloat(tx.value) / 1e18;
const sym = SafeAPI.CHAINS[chainId]?.symbol || 'ETH';
outTransfers.push({ to: tx.to, value: val, symbol: sym });
}
if (tx.dataDecoded?.method === 'transfer') {
const params = tx.dataDecoded.parameters || [];
const to = params.find(p => p.name === 'to')?.value;
const rawVal = params.find(p => p.name === 'value')?.value || '0';
if (to) outTransfers.push({ to, value: parseFloat(rawVal) / 1e18, symbol: 'Token' });
}
if (tx.dataDecoded?.method === 'multiSend') {
const txsParam = tx.dataDecoded.parameters?.find(p => p.name === 'transactions');
if (txsParam?.valueDecoded) {
for (const inner of txsParam.valueDecoded) {
if (inner.value && inner.value !== '0' && inner.to) {
const val = parseFloat(inner.value) / 1e18;
const sym = SafeAPI.CHAINS[chainId]?.symbol || 'ETH';
outTransfers.push({ to: inner.to, value: val, symbol: sym });
}
if (inner.dataDecoded?.method === 'transfer') {
const to2 = inner.dataDecoded.parameters?.find(p => p.name === 'to')?.value;
const raw2 = inner.dataDecoded.parameters?.find(p => p.name === 'value')?.value || '0';
if (to2) outTransfers.push({ to: to2, value: parseFloat(raw2) / 1e18, symbol: 'Token' });
}
}
}
}
for (const t of outTransfers) {
const usd = estimateUSD(t.value, t.symbol);
const usdVal = usd !== null ? usd : t.value;
chainOutflow += usdVal;
if (t.to) {
chainAddresses.add(t.to.toLowerCase());
allAddresses.add(t.to.toLowerCase());
}
const key = shortenAddress(t.to);
const existing = outflowAgg.get(key) || { to: shortenAddress(t.to), value: 0, token: t.symbol };
existing.value += usdVal;
outflowAgg.set(key, existing);
allTransfers.outgoing.push({
chainId,
chainName,
date: date || '',
to: t.to,
toShort: shortenAddress(t.to),
token: t.symbol,
amount: t.value,
usd: usdVal,
});
}
}
}
// Build flow entries from aggregated outflows
for (const [, agg] of outflowAgg) {
flows.push({
from: 'Safe Wallet',
to: agg.to,
value: Math.round(agg.value),
token: agg.token,
chain: chainName,
});
}
// Format dates
const fmt = d => d ? d.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '?';
const period = (chainMinDate && chainMaxDate)
? `${fmt(chainMinDate)} - ${fmt(chainMaxDate)}`
: 'No data';
chainStats[chainName] = {
transfers: chainTransfers,
inflow: formatUSD(chainInflow),
outflow: formatUSD(chainOutflow),
addresses: String(chainAddresses.size),
period,
};
flowData[chainName] = flows;
totalTransfers += chainTransfers;
totalInflow += chainInflow;
totalOutflow += chainOutflow;
if (chainMinDate && (!minDate || chainMinDate < minDate)) minDate = chainMinDate;
if (chainMaxDate && (!maxDate || chainMaxDate > maxDate)) maxDate = chainMaxDate;
}
// Aggregate "all" stats
const fmt = d => d ? d.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '?';
chainStats['all'] = {
transfers: totalTransfers,
inflow: formatUSD(totalInflow),
outflow: formatUSD(totalOutflow),
addresses: String(allAddresses.size),
period: (minDate && maxDate) ? `${fmt(minDate)} - ${fmt(maxDate)}` : 'No data',
};
// Aggregate "all" flows: merge top flows from each chain
const allFlows = [];
for (const [, flows] of Object.entries(flowData)) {
allFlows.push(...flows);
}
// Keep top 15 by value
allFlows.sort((a, b) => b.value - a.value);
flowData['all'] = allFlows.slice(0, 15);
// Sort transfers by date
allTransfers.incoming.sort((a, b) => new Date(b.date) - new Date(a.date));
allTransfers.outgoing.sort((a, b) => new Date(b.date) - new Date(a.date));
return { chainStats, flowData, allTransfers };
}
function formatUSD(value) {
if (value >= 1000000) return `~$${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `~$${Math.round(value / 1000)}K`;
return `~$${Math.round(value)}`;
}
// ─── Public API ────────────────────────────────────────────────
return {
shortenAddress,
explorerLink,
txExplorerLink,
getTransferValue,
getTokenSymbol,
getTokenName,
estimateUSD,
transformToTimelineData,
transformToSankeyData,
transformToMultichainData,
formatUSD,
STABLECOINS,
};
})();

115
js/router.js Normal file
View File

@ -0,0 +1,115 @@
/**
* Simple URL Router for rWallet.online
* Manages wallet address and chain state across pages via URL parameters.
*/
const Router = (() => {
/**
* Parse URL parameters from current page.
* Returns { address, chain, chainId }
*/
function getParams() {
const params = new URLSearchParams(window.location.search);
return {
address: params.get('address') || '',
chain: params.get('chain') || 'all',
chainId: params.get('chainId') ? parseInt(params.get('chainId')) : null,
};
}
/**
* Build a URL with wallet parameters for navigation between viz pages.
*/
function buildUrl(page, address, chain, chainId) {
const params = new URLSearchParams();
if (address) params.set('address', address);
if (chain && chain !== 'all') params.set('chain', chain);
if (chainId) params.set('chainId', String(chainId));
const qs = params.toString();
return qs ? `${page}?${qs}` : page;
}
/**
* Navigate to a visualization page with current wallet context.
*/
function navigateTo(page) {
const { address, chain, chainId } = getParams();
window.location.href = buildUrl(page, address, chain, chainId);
}
/**
* Update URL parameters without page reload (for filter changes etc.)
*/
function updateParams(updates) {
const current = getParams();
const merged = { ...current, ...updates };
const params = new URLSearchParams();
if (merged.address) params.set('address', merged.address);
if (merged.chain && merged.chain !== 'all') params.set('chain', merged.chain);
if (merged.chainId) params.set('chainId', String(merged.chainId));
const newUrl = `${window.location.pathname}?${params.toString()}`;
window.history.replaceState({}, '', newUrl);
}
/**
* Validate an Ethereum address format.
*/
function isValidAddress(address) {
return /^0x[a-fA-F0-9]{40}$/.test(address);
}
/**
* Create a standard wallet address input bar for visualization pages.
* Returns the input element for event binding.
*/
function createAddressBar(containerId) {
const { address } = getParams();
const container = document.getElementById(containerId);
if (!container) return null;
container.innerHTML = `
<div class="address-bar">
<div class="address-bar-inner">
<a href="index.html" class="back-link" title="Back to rWallet.online">
<span class="back-icon">&#8592;</span>
<span class="back-text">rWallet</span>
</a>
<input type="text" id="wallet-input" placeholder="Enter Safe wallet address (0x...)"
value="${address}" spellcheck="false" autocomplete="off" />
<button id="load-wallet-btn" title="Load wallet">Explore</button>
</div>
</div>
`;
const input = document.getElementById('wallet-input');
const btn = document.getElementById('load-wallet-btn');
function loadWallet() {
const addr = input.value.trim();
if (!isValidAddress(addr)) {
input.style.borderColor = '#f87171';
setTimeout(() => input.style.borderColor = '', 2000);
return;
}
updateParams({ address: addr });
// Dispatch custom event for the page to handle
window.dispatchEvent(new CustomEvent('wallet-changed', { detail: { address: addr } }));
}
btn.addEventListener('click', loadWallet);
input.addEventListener('keydown', e => { if (e.key === 'Enter') loadWallet(); });
return input;
}
// ─── Public API ────────────────────────────────────────────────
return {
getParams,
buildUrl,
navigateTo,
updateParams,
isValidAddress,
createAddressBar,
};
})();

199
js/safe-api.js Normal file
View File

@ -0,0 +1,199 @@
/**
* Safe Global API Client for rWallet.online
* Browser-side client for Safe Transaction Service API
* Chain config adapted from payment-infra/packages/safe-core/src/chains.ts
*/
const SafeAPI = (() => {
// ─── Chain Configuration ───────────────────────────────────────
const CHAINS = {
1: { name: 'Ethereum', slug: 'mainnet', txService: 'https://safe-transaction-mainnet.safe.global', explorer: 'https://etherscan.io', color: '#627eea', symbol: 'ETH' },
10: { name: 'Optimism', slug: 'optimism', txService: 'https://safe-transaction-optimism.safe.global', explorer: 'https://optimistic.etherscan.io', color: '#ff0420', symbol: 'ETH' },
100: { name: 'Gnosis', slug: 'gnosis-chain', txService: 'https://safe-transaction-gnosis-chain.safe.global', explorer: 'https://gnosisscan.io', color: '#04795b', symbol: 'xDAI' },
137: { name: 'Polygon', slug: 'polygon', txService: 'https://safe-transaction-polygon.safe.global', explorer: 'https://polygonscan.com', color: '#8247e5', symbol: 'POL' },
8453: { name: 'Base', slug: 'base', txService: 'https://safe-transaction-base.safe.global', explorer: 'https://basescan.org', color: '#0052ff', symbol: 'ETH' },
42161: { name: 'Arbitrum', slug: 'arbitrum', txService: 'https://safe-transaction-arbitrum.safe.global', explorer: 'https://arbiscan.io', color: '#28a0f0', symbol: 'ETH' },
43114: { name: 'Avalanche', slug: 'avalanche', txService: 'https://safe-transaction-avalanche.safe.global', explorer: 'https://snowtrace.io', color: '#e84142', symbol: 'AVAX' },
};
// ─── Helpers ───────────────────────────────────────────────────
function getChain(chainId) {
const chain = CHAINS[chainId];
if (!chain) throw new Error(`Unsupported chain ID: ${chainId}`);
return chain;
}
function apiUrl(chainId, path) {
return `${getChain(chainId).txService}/api/v1${path}`;
}
async function fetchJSON(url) {
const res = await fetch(url);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`API error ${res.status}: ${res.statusText} (${url})`);
return res.json();
}
// ─── Core API Methods ──────────────────────────────────────────
/**
* Get Safe info (owners, threshold, nonce, etc.)
*/
async function getSafeInfo(address, chainId) {
const data = await fetchJSON(apiUrl(chainId, `/safes/${address}/`));
if (!data) return null;
return {
address: data.address,
nonce: data.nonce,
threshold: data.threshold,
owners: data.owners,
modules: data.modules,
fallbackHandler: data.fallbackHandler,
guard: data.guard,
version: data.version,
chainId,
};
}
/**
* Get token + native balances
*/
async function getBalances(address, chainId) {
const data = await fetchJSON(apiUrl(chainId, `/safes/${address}/balances/?trusted=true&exclude_spam=true`));
if (!data) return [];
return data.map(b => ({
tokenAddress: b.tokenAddress,
token: b.token ? {
name: b.token.name,
symbol: b.token.symbol,
decimals: b.token.decimals,
logoUri: b.token.logoUri,
} : null,
balance: b.balance,
// Human-readable balance
balanceFormatted: b.token
? (parseFloat(b.balance) / Math.pow(10, b.token.decimals)).toFixed(b.token.decimals > 6 ? 4 : 2)
: (parseFloat(b.balance) / 1e18).toFixed(4),
symbol: b.token ? b.token.symbol : CHAINS[chainId]?.symbol || 'ETH',
fiatBalance: b.fiatBalance || '0',
fiatConversion: b.fiatConversion || '0',
}));
}
/**
* Fetch all multisig transactions (paginated)
*/
async function getAllMultisigTransactions(address, chainId, limit = 100) {
const allTxs = [];
let url = apiUrl(chainId, `/safes/${address}/multisig-transactions/?limit=${limit}&ordering=-executionDate`);
while (url) {
const data = await fetchJSON(url);
if (!data || !data.results) break;
allTxs.push(...data.results);
url = data.next;
// Safety: cap at 1000 transactions
if (allTxs.length >= 1000) break;
}
return allTxs;
}
/**
* Fetch all incoming transfers (paginated)
*/
async function getAllIncomingTransfers(address, chainId, limit = 100) {
const allTransfers = [];
let url = apiUrl(chainId, `/safes/${address}/incoming-transfers/?limit=${limit}`);
while (url) {
const data = await fetchJSON(url);
if (!data || !data.results) break;
allTransfers.push(...data.results);
url = data.next;
if (allTransfers.length >= 1000) break;
}
return allTransfers;
}
/**
* Fetch all-transactions (combines multisig + module + incoming)
*/
async function getAllTransactions(address, chainId, limit = 100) {
const allTxs = [];
let url = apiUrl(chainId, `/safes/${address}/all-transactions/?limit=${limit}&ordering=-executionDate&executed=true`);
while (url) {
const data = await fetchJSON(url);
if (!data || !data.results) break;
allTxs.push(...data.results);
url = data.next;
if (allTxs.length >= 1000) break;
}
return allTxs;
}
/**
* Detect which chains have a Safe deployed for this address.
* Checks all supported chains in parallel.
* Returns array of { chainId, chain, safeInfo }
*/
async function detectSafeChains(address) {
const checks = Object.entries(CHAINS).map(async ([chainId, chain]) => {
try {
const info = await getSafeInfo(address, parseInt(chainId));
if (info) return { chainId: parseInt(chainId), chain, safeInfo: info };
} catch (e) {
// Chain doesn't have this Safe or API error - skip
}
return null;
});
const results = await Promise.all(checks);
return results.filter(Boolean);
}
/**
* Fetch comprehensive wallet data for a single chain.
* Returns { info, balances, outgoing, incoming }
*/
async function fetchChainData(address, chainId) {
const [info, balances, outgoing, incoming] = await Promise.all([
getSafeInfo(address, chainId),
getBalances(address, chainId),
getAllMultisigTransactions(address, chainId),
getAllIncomingTransfers(address, chainId),
]);
return { chainId, info, balances, outgoing, incoming };
}
/**
* Fetch wallet data across all detected chains.
* Returns Map<chainId, chainData>
*/
async function fetchAllChainsData(address, detectedChains) {
const dataMap = new Map();
const fetches = detectedChains.map(async ({ chainId }) => {
const data = await fetchChainData(address, chainId);
dataMap.set(chainId, data);
});
await Promise.all(fetches);
return dataMap;
}
// ─── Public API ────────────────────────────────────────────────
return {
CHAINS,
getChain,
getSafeInfo,
getBalances,
getAllMultisigTransactions,
getAllIncomingTransfers,
getAllTransactions,
detectSafeChains,
fetchChainData,
fetchAllChainsData,
};
})();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -3,9 +3,12 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wallet Flow Visualization - 0x2956...7D1</title>
<title>Single-Chain Flow | rWallet.online</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3-sankey@0.12.3/dist/d3-sankey.min.js"></script>
<script src="js/safe-api.js"></script>
<script src="js/data-transform.js"></script>
<script src="js/router.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
@ -15,534 +18,418 @@
min-height: 100vh;
padding: 20px;
}
h1 {
text-align: center;
margin-bottom: 10px;
color: #00d4ff;
font-size: 1.8rem;
h1 { text-align: center; margin-bottom: 10px; color: #00d4ff; font-size: 1.8rem; }
.subtitle { text-align: center; color: #888; margin-bottom: 20px; font-family: monospace; font-size: 0.9rem; }
.container { max-width: 1400px; margin: 0 auto; }
/* Address Bar */
.address-bar { margin-bottom: 24px; }
.address-bar-inner {
display: flex; gap: 10px; align-items: center; max-width: 700px; margin: 0 auto;
}
.subtitle {
text-align: center;
color: #888;
margin-bottom: 30px;
font-family: monospace;
font-size: 0.9rem;
.back-link {
color: #00d4ff; text-decoration: none; font-weight: 600; font-size: 0.9rem;
display: flex; align-items: center; gap: 4px; white-space: nowrap;
padding: 8px 12px; border-radius: 8px; border: 1px solid rgba(0,212,255,0.3);
background: rgba(0,212,255,0.05); transition: all 0.2s;
}
.container {
max-width: 1400px;
margin: 0 auto;
.back-link:hover { background: rgba(0,212,255,0.1); }
.back-icon { font-size: 1.1rem; }
#wallet-input {
flex: 1; padding: 10px 16px; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.05);
color: #e0e0e0; font-family: monospace; font-size: 0.9rem; outline: none;
transition: border-color 0.3s;
}
#wallet-input:focus { border-color: #00d4ff; }
#load-wallet-btn {
padding: 10px 20px; border-radius: 8px; border: none;
background: #00d4ff; color: #000; font-weight: 600; cursor: pointer;
transition: background 0.2s;
}
#load-wallet-btn:hover { background: #00b8d9; }
/* Chain Selector */
.chain-selector {
display: flex; justify-content: center; gap: 10px; margin-bottom: 24px; flex-wrap: wrap;
}
.chain-btn {
padding: 8px 16px; border-radius: 8px;
border: 2px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03);
cursor: pointer; transition: all 0.3s; color: #e0e0e0; font-size: 0.85rem;
}
.chain-btn:hover { background: rgba(255,255,255,0.08); }
.chain-btn.active { border-color: var(--chain-color, #00d4ff); background: rgba(255,255,255,0.1); }
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 30px;
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px; margin-bottom: 30px;
}
.stat-card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
background: rgba(255,255,255,0.05); border-radius: 12px; padding: 20px;
border: 1px solid rgba(255,255,255,0.1);
}
.stat-card h3 {
color: #888;
font-size: 0.8rem;
text-transform: uppercase;
margin-bottom: 8px;
}
.stat-card .value {
font-size: 1.5rem;
font-weight: bold;
}
.stat-card h3 { color: #888; font-size: 0.8rem; text-transform: uppercase; margin-bottom: 8px; }
.stat-card .value { font-size: 1.5rem; font-weight: bold; }
.stat-card.inflow .value { color: #4ade80; }
.stat-card.outflow .value { color: #f87171; }
.stat-card.neutral .value { color: #00d4ff; }
/* Sankey */
#sankey-chart {
background: rgba(255,255,255,0.02);
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.1);
margin-bottom: 30px;
}
.node rect {
stroke: #333;
stroke-width: 1px;
}
.node text {
font-size: 11px;
fill: #e0e0e0;
}
.link {
fill: none;
stroke-opacity: 0.4;
}
.link:hover {
stroke-opacity: 0.7;
}
.tables-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 1000px) {
.tables-grid { grid-template-columns: 1fr; }
background: rgba(255,255,255,0.02); border-radius: 12px;
border: 1px solid rgba(255,255,255,0.1); margin-bottom: 30px;
}
.node rect { stroke: #333; stroke-width: 1px; }
.node text { font-size: 11px; fill: #e0e0e0; }
.link { fill: none; stroke-opacity: 0.4; }
.link:hover { stroke-opacity: 0.7; }
/* Tables */
.tables-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 1000px) { .tables-grid { grid-template-columns: 1fr; } }
.table-section {
background: rgba(255,255,255,0.03);
border-radius: 12px;
padding: 20px;
background: rgba(255,255,255,0.03); border-radius: 12px; padding: 20px;
border: 1px solid rgba(255,255,255,0.1);
}
.table-section h2 {
margin-bottom: 15px;
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 10px;
}
.table-section h2 { margin-bottom: 15px; font-size: 1.1rem; display: flex; align-items: center; gap: 10px; }
.table-section h2.inflow { color: #4ade80; }
.table-section h2.outflow { color: #f87171; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th {
text-align: left;
padding: 10px 8px;
border-bottom: 2px solid rgba(255,255,255,0.1);
color: #888;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
}
td {
padding: 10px 8px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
tr:hover td {
background: rgba(255,255,255,0.03);
}
.address {
font-family: monospace;
font-size: 0.8rem;
color: #00d4ff;
}
.address a {
color: #00d4ff;
text-decoration: none;
}
.address a:hover {
text-decoration: underline;
}
.amount {
font-weight: 600;
text-align: right;
text-align: left; padding: 10px 8px; border-bottom: 2px solid rgba(255,255,255,0.1);
color: #888; font-weight: 600; text-transform: uppercase; font-size: 0.75rem;
position: sticky; top: 0; background: #1a1a2e; z-index: 10;
}
td { padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.05); }
tr:hover td { background: rgba(255,255,255,0.03); }
.address { font-family: monospace; font-size: 0.8rem; color: #00d4ff; }
.address a { color: #00d4ff; text-decoration: none; }
.address a:hover { text-decoration: underline; }
.amount { font-weight: 600; text-align: right; }
.amount.positive { color: #4ade80; }
.amount.negative { color: #f87171; }
.token {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 0.75rem; font-weight: 600; background: #555;
}
.token.wxdai { background: #fbbf24; color: #000; }
.token.tec { background: #8b5cf6; color: #fff; }
.token.zrc { background: #06b6d4; color: #000; }
.token.spam { background: #666; color: #999; }
.table-scroll { max-height: 500px; overflow-y: auto; }
.legend {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 3px;
}
/* Legend */
.legend { display: flex; justify-content: center; gap: 20px; margin-bottom: 20px; flex-wrap: wrap; font-size: 0.85rem; }
.legend-item { display: flex; align-items: center; gap: 8px; }
.legend-color { width: 16px; height: 16px; border-radius: 3px; }
.tooltip {
position: absolute;
background: rgba(0,0,0,0.9);
color: #fff;
padding: 10px 15px;
border-radius: 8px;
font-size: 0.85rem;
pointer-events: none;
z-index: 1000;
max-width: 300px;
/* Loading */
.loading {
text-align: center; padding: 80px 20px; color: #888;
}
.loading .spinner {
width: 40px; height: 40px; border: 3px solid rgba(0,212,255,0.2);
border-top-color: #00d4ff; border-radius: 50%;
animation: spin 0.8s linear infinite; margin: 0 auto 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.error { text-align: center; padding: 60px 20px; color: #f87171; }
.empty { text-align: center; padding: 60px 20px; color: #666; }
.spam-warning {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
font-size: 0.85rem;
background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 12px; padding: 14px 20px; margin-bottom: 24px; font-size: 0.85rem;
display: flex; align-items: center; gap: 12px;
}
.spam-warning strong { color: #f87171; }
</style>
</head>
<body>
<div class="container">
<h1>Wallet Transaction Flow</h1>
<p class="subtitle">gno:0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1</p>
<p class="subtitle" id="wallet-subtitle">Enter a Safe wallet address to visualize</p>
<div class="stats-grid">
<div class="stat-card inflow">
<h3>Total Inflow (WXDAI)</h3>
<div class="value">+20,197 DAI</div>
</div>
<div class="stat-card outflow">
<h3>Total Outflow (WXDAI)</h3>
<div class="value">-17,697 DAI</div>
</div>
<div class="stat-card inflow">
<h3>Total Inflow (TEC)</h3>
<div class="value">+14,336 TEC</div>
</div>
<div class="stat-card outflow">
<h3>Total Outflow (TEC)</h3>
<div class="value">-13,336 TEC</div>
</div>
<div class="stat-card neutral">
<h3>Unique Counterparties</h3>
<div class="value">8 addresses</div>
</div>
<div class="stat-card neutral">
<h3>Active Period</h3>
<div class="value">Mar 2023 - Dec 2023</div>
</div>
</div>
<div id="address-bar-container"></div>
<div class="spam-warning">
<strong>⚠️ Note:</strong> This wallet received several spam/scam NFTs from null address (0x000...000) including fake "USDT reward", "ETH Airdrop", and phishing tokens. These are excluded from the legitimate flow analysis below.
</div>
<div id="chain-selector" class="chain-selector" style="display:none;"></div>
<div class="legend">
<div class="legend-item"><div class="legend-color" style="background: #fbbf24"></div> WXDAI</div>
<div class="legend-item"><div class="legend-color" style="background: #8b5cf6"></div> TEC</div>
<div class="legend-item"><div class="legend-color" style="background: #4ade80"></div> Inflow</div>
<div class="legend-item"><div class="legend-color" style="background: #f87171"></div> Outflow</div>
</div>
<div id="sankey-chart"></div>
<div class="tables-grid">
<div class="table-section">
<h2 class="inflow">↓ Incoming Transfers</h2>
<table>
<thead>
<tr>
<th>Date</th>
<th>From</th>
<th>Token</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>2023-03-28</td>
<td class="address"><a href="https://gnosisscan.io/address/0x01d9c9Ca040e90fEB47c7513d9A3574f6e1317bD" target="_blank">0x01d9...17bD</a></td>
<td><span class="token wxdai">WXDAI</span></td>
<td class="amount positive">+17,000.00</td>
</tr>
<tr>
<td>2023-03-22</td>
<td class="address"><a href="https://gnosisscan.io/address/0x763d7D362B59aeA3858a92a302e18cd41b1252d4" target="_blank">0x763d...87d4</a></td>
<td><span class="token tec">TEC</span></td>
<td class="amount positive">+1.00</td>
</tr>
<tr>
<td>2023-07-05</td>
<td class="address"><a href="https://gnosisscan.io/address/0x9b55D80Af9dd8D23C372915Ad55c010799010b4d" target="_blank">0x9b55...0b4d</a></td>
<td><span class="token tec">TEC</span></td>
<td class="amount positive">+3,624.84</td>
</tr>
<tr>
<td>2023-10-04</td>
<td class="address"><a href="https://gnosisscan.io/address/0x763d7D362B59aeA3858a92a302e18cd41b1252d4" target="_blank">0x763d...87d4</a></td>
<td><span class="token wxdai">WXDAI</span></td>
<td class="amount positive">+631.09</td>
</tr>
<tr>
<td>2023-10-14</td>
<td class="address"><a href="https://gnosisscan.io/address/0x5138E41b6E66288e273f16380278ffF784ceAd00" target="_blank">0x5138...Ad00</a></td>
<td><span class="token tec">TEC</span></td>
<td class="amount positive">+9,710.03</td>
</tr>
<tr>
<td>2023-10-18</td>
<td class="address"><a href="https://gnosisscan.io/address/0x9b55D80Af9dd8D23C372915Ad55c010799010b4d" target="_blank">0x9b55...0b4d</a></td>
<td><span class="token wxdai">WXDAI</span></td>
<td class="amount positive">+2,566.40</td>
</tr>
<tr>
<td>2024-05-08</td>
<td class="address"><a href="https://gnosisscan.io/address/0xf6A78083ca3e2a662D6dd1703c939c8aCE2e268d" target="_blank">0xf6A7...268d</a></td>
<td><span class="token zrc">ZRC</span></td>
<td class="amount positive">+500.00</td>
</tr>
<tr>
<td>2024-05-14</td>
<td class="address"><a href="https://gnosisscan.io/address/0xf6A78083ca3e2a662D6dd1703c939c8aCE2e268d" target="_blank">0xf6A7...268d</a></td>
<td><span class="token zrc">ZRC</span></td>
<td class="amount positive">+500.00</td>
</tr>
</tbody>
</table>
</div>
<div class="table-section">
<h2 class="outflow">↑ Outgoing Transfers</h2>
<table>
<thead>
<tr>
<th>Date</th>
<th>To</th>
<th>Token</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>2023-04-26</td>
<td class="address"><a href="https://gnosisscan.io/address/0x9b55D80Af9dd8D23C372915Ad55c010799010b4d" target="_blank">0x9b55...0b4d</a></td>
<td><span class="token wxdai">WXDAI</span></td>
<td class="amount negative">-2,306.00</td>
</tr>
<tr>
<td>2023-04-26</td>
<td class="address"><a href="https://gnosisscan.io/address/0x763d7D362B59aeA3858a92a302e18cd41b1252d4" target="_blank">0x763d...87d4</a></td>
<td><span class="token wxdai">WXDAI</span></td>
<td class="amount negative">-1,050.00</td>
</tr>
<tr>
<td>2023-04-26</td>
<td class="address"><a href="https://gnosisscan.io/address/0x1409a9ef3450D5d50aAd004f417436e772FbF8fC" target="_blank">0x1409...8fC</a></td>
<td><span class="token wxdai">WXDAI</span></td>
<td class="amount negative">-910.00</td>
</tr>
<tr>
<td>2023-05-11</td>
<td class="address"><a href="https://gnosisscan.io/address/0xb2821C0DF0c414ff51D3e8033CBA26DF6AaC587b" target="_blank">0xb282...587b</a></td>
<td><span class="token wxdai">WXDAI</span></td>
<td class="amount negative">-500.00</td>
</tr>
<tr>
<td>2023-06-07</td>
<td class="address"><a href="https://gnosisscan.io/address/0x9b55D80Af9dd8D23C372915Ad55c010799010b4d" target="_blank">0x9b55...0b4d</a></td>
<td><span class="token wxdai">WXDAI</span></td>
<td class="amount negative">-3,235.00</td>
</tr>
<tr>
<td>2023-06-07</td>
<td class="address"><a href="https://gnosisscan.io/address/0x763d7D362B59aeA3858a92a302e18cd41b1252d4" target="_blank">0x763d...87d4</a></td>
<td><span class="token wxdai">WXDAI</span></td>
<td class="amount negative">-2,280.00</td>
</tr>
<tr>
<td>2023-06-07</td>
<td class="address"><a href="https://gnosisscan.io/address/0x778549Eb292AC98A96a05E122967f22eFA003707" target="_blank">0x7785...3707</a></td>
<td><span class="token wxdai">WXDAI</span></td>
<td class="amount negative">-1,765.00</td>
</tr>
<tr>
<td>2023-06-07</td>
<td class="address"><a href="https://gnosisscan.io/address/0x9239E42792aa0C6881ecFaf73F1ecF0F01C60A14" target="_blank">0x9239...0A14</a></td>
<td><span class="token wxdai">WXDAI</span></td>
<td class="amount negative">-1,200.00</td>
</tr>
<tr>
<td>2023-06-07</td>
<td class="address"><a href="https://gnosisscan.io/address/0xb2821C0DF0c414ff51D3e8033CBA26DF6AaC587b" target="_blank">0xb282...587b</a></td>
<td><span class="token wxdai">WXDAI</span></td>
<td class="amount negative">-445.00</td>
</tr>
<tr>
<td>2023-09-10</td>
<td class="address"><a href="https://gnosisscan.io/address/0x9b55D80Af9dd8D23C372915Ad55c010799010b4d" target="_blank">0x9b55...0b4d</a></td>
<td><span class="token wxdai">WXDAI</span></td>
<td class="amount negative">-3,309.00</td>
</tr>
<tr>
<td>2023-10-04</td>
<td class="address"><a href="https://gnosisscan.io/address/0x763d7D362B59aeA3858a92a302e18cd41b1252d4" target="_blank">0x763d...87d4</a></td>
<td><span class="token tec">TEC</span></td>
<td class="amount negative">-1,531.29</td>
</tr>
<tr>
<td>2023-10-18</td>
<td class="address"><a href="https://gnosisscan.io/address/0x9b55D80Af9dd8D23C372915Ad55c010799010b4d" target="_blank">0x9b55...0b4d</a></td>
<td><span class="token tec">TEC</span></td>
<td class="amount negative">-5,900.00</td>
</tr>
<tr>
<td>2023-10-26</td>
<td class="address"><a href="https://gnosisscan.io/address/0x9b55D80Af9dd8D23C372915Ad55c010799010b4d" target="_blank">0x9b55...0b4d</a></td>
<td><span class="token wxdai">WXDAI</span></td>
<td class="amount negative">-2,500.00</td>
</tr>
<tr>
<td>2023-11-01</td>
<td class="address"><a href="https://gnosisscan.io/address/0xb2821C0DF0c414ff51D3e8033CBA26DF6AaC587b" target="_blank">0xb282...587b</a></td>
<td><span class="token tec">TEC</span></td>
<td class="amount negative">-236.00</td>
</tr>
<tr>
<td>2023-11-01</td>
<td class="address"><a href="https://gnosisscan.io/address/0x9239E42792aa0C6881ecFaf73F1ecF0F01C60A14" target="_blank">0x9239...0A14</a></td>
<td><span class="token wxdai">WXDAI</span></td>
<td class="amount negative">-500.00</td>
</tr>
<tr>
<td>2023-12-15</td>
<td class="address"><a href="https://gnosisscan.io/address/0x778549Eb292AC98A96a05E122967f22eFA003707" target="_blank">0x7785...3707</a></td>
<td><span class="token tec">TEC</span></td>
<td class="amount negative">-5,668.58</td>
</tr>
<tr>
<td>2023-12-15</td>
<td class="address"><a href="https://gnosisscan.io/address/0x778549Eb292AC98A96a05E122967f22eFA003707" target="_blank">0x7785...3707</a></td>
<td><span class="token wxdai">WXDAI</span></td>
<td class="amount negative">-197.49</td>
</tr>
</tbody>
</table>
<div id="content">
<div class="empty">
<p style="font-size:1.2rem; margin-bottom:8px;">Enter a Safe wallet address above to get started</p>
<p>Or try the demo: <a href="?address=0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1&chainId=100" style="color:#00d4ff;">TEC Commons Fund</a></p>
</div>
</div>
</div>
<script>
// Sankey diagram data
const sankeyData = {
nodes: [
// Inflow sources (left side)
{ name: "0x01d9...17bD", type: "source" }, // 0 - large WXDAI source
{ name: "0x5138...Ad00", type: "source" }, // 1 - TEC source
{ name: "0x9b55...0b4d", type: "source" }, // 2 - bidirectional
{ name: "0x763d...87d4", type: "source" }, // 3 - bidirectional
{ name: "0xf6A7...268d", type: "source" }, // 4 - ZRC source
// Initialize address bar
Router.createAddressBar('address-bar-container');
// Central wallet
{ name: "Safe Wallet", type: "wallet" }, // 5
let currentChainId = null;
let allChainData = null;
let safeAddress = '';
// Outflow targets (right side)
{ name: "0x9b55...0b4d (out)", type: "target" }, // 6
{ name: "0x763d...87d4 (out)", type: "target" }, // 7
{ name: "0x7785...3707", type: "target" }, // 8
{ name: "0x9239...0A14", type: "target" }, // 9
{ name: "0xb282...587b", type: "target" }, // 10
{ name: "0x1409...8fC", type: "target" }, // 11
],
links: [
// Inflows (WXDAI)
{ source: 0, target: 5, value: 17000, token: "WXDAI" },
{ source: 2, target: 5, value: 2566, token: "WXDAI" },
{ source: 3, target: 5, value: 631, token: "WXDAI" },
async function loadWallet(address) {
safeAddress = address;
const content = document.getElementById('content');
const subtitle = document.getElementById('wallet-subtitle');
// Inflows (TEC)
{ source: 1, target: 5, value: 9710, token: "TEC" },
{ source: 2, target: 5, value: 3625, token: "TEC" },
{ source: 3, target: 5, value: 1, token: "TEC" },
subtitle.textContent = address;
content.innerHTML = '<div class="loading"><div class="spinner"></div><p>Detecting Safe wallets across chains...</p></div>';
// Inflows (ZRC)
{ source: 4, target: 5, value: 1000, token: "ZRC" },
try {
// Detect chains
const detected = await SafeAPI.detectSafeChains(address);
if (detected.length === 0) {
content.innerHTML = '<div class="error"><p style="font-size:1.2rem;">No Safe wallet found at this address</p><p style="color:#888;margin-top:8px;">This tool works with Safe (Gnosis Safe) multi-sig wallets.</p></div>';
return;
}
// Outflows (WXDAI)
{ source: 5, target: 6, value: 11350, token: "WXDAI" },
{ source: 5, target: 7, value: 3330, token: "WXDAI" },
{ source: 5, target: 8, value: 1962, token: "WXDAI" },
{ source: 5, target: 9, value: 1700, token: "WXDAI" },
{ source: 5, target: 10, value: 945, token: "WXDAI" },
{ source: 5, target: 11, value: 910, token: "WXDAI" },
// Show chain selector
const selector = document.getElementById('chain-selector');
selector.style.display = 'flex';
selector.innerHTML = detected.map(({ chainId, chain }) =>
`<button class="chain-btn" data-chain-id="${chainId}" style="--chain-color:${chain.color}">${chain.name}</button>`
).join('');
// Outflows (TEC)
{ source: 5, target: 6, value: 5900, token: "TEC" },
{ source: 5, target: 8, value: 5669, token: "TEC" },
{ source: 5, target: 7, value: 1531, token: "TEC" },
{ source: 5, target: 10, value: 236, token: "TEC" },
]
};
// Bind chain selector clicks
selector.querySelectorAll('.chain-btn').forEach(btn => {
btn.addEventListener('click', () => {
selector.querySelectorAll('.chain-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const cid = parseInt(btn.dataset.chainId);
currentChainId = cid;
renderChain(cid);
});
});
const tokenColors = {
"WXDAI": "#fbbf24",
"TEC": "#8b5cf6",
"ZRC": "#06b6d4"
};
// Check URL param or default to first chain
const params = Router.getParams();
const targetChainId = params.chainId || detected[0].chainId;
currentChainId = targetChainId;
// Create Sankey chart
const width = 1200;
const height = 600;
const margin = { top: 20, right: 200, bottom: 20, left: 200 };
content.innerHTML = '<div class="loading"><div class="spinner"></div><p>Fetching transaction data...</p></div>';
const svg = d3.select("#sankey-chart")
.append("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`);
// Fetch data for all detected chains (we'll cache it)
allChainData = await SafeAPI.fetchAllChainsData(address, detected);
const sankey = d3.sankey()
.nodeWidth(20)
.nodePadding(15)
.extent([[margin.left, margin.top], [width - margin.right, height - margin.bottom]]);
// Activate the target chain button
const targetBtn = selector.querySelector(`[data-chain-id="${targetChainId}"]`);
if (targetBtn) targetBtn.classList.add('active');
const { nodes, links } = sankey({
nodes: sankeyData.nodes.map(d => Object.assign({}, d)),
links: sankeyData.links.map(d => Object.assign({}, d))
});
renderChain(targetChainId);
// Add links
svg.append("g")
.selectAll("path")
.data(links)
.join("path")
.attr("class", "link")
.attr("d", d3.sankeyLinkHorizontal())
.attr("stroke", d => tokenColors[d.token] || "#888")
.attr("stroke-width", d => Math.max(1, d.width))
.append("title")
.text(d => `${d.source.name} → ${d.target.name}\n${d.value.toLocaleString()} ${d.token}`);
} catch (err) {
content.innerHTML = `<div class="error"><p>Error: ${err.message}</p></div>`;
console.error(err);
}
}
// Add nodes
const node = svg.append("g")
.selectAll("g")
.data(nodes)
.join("g");
function renderChain(chainId) {
const content = document.getElementById('content');
const chainData = allChainData?.get(chainId);
const chain = SafeAPI.CHAINS[chainId];
node.append("rect")
.attr("x", d => d.x0)
.attr("y", d => d.y0)
.attr("height", d => d.y1 - d.y0)
.attr("width", d => d.x1 - d.x0)
.attr("fill", d => d.type === "wallet" ? "#00d4ff" : d.type === "source" ? "#4ade80" : "#f87171")
.attr("rx", 3);
if (!chainData) {
content.innerHTML = '<div class="error"><p>No data for this chain</p></div>';
return;
}
node.append("text")
.attr("x", d => d.x0 < width / 2 ? d.x0 - 6 : d.x1 + 6)
.attr("y", d => (d.y1 + d.y0) / 2)
.attr("dy", "0.35em")
.attr("text-anchor", d => d.x0 < width / 2 ? "end" : "start")
.text(d => d.name)
.style("font-family", "monospace")
.style("font-size", d => d.type === "wallet" ? "14px" : "11px")
.style("font-weight", d => d.type === "wallet" ? "bold" : "normal");
// Transform to Sankey format
const sankeyData = DataTransform.transformToSankeyData(chainData, safeAddress);
if (sankeyData.links.length === 0) {
content.innerHTML = '<div class="empty"><p>No significant transactions found on this chain</p></div>';
return;
}
// Calculate stats
let totalInflow = 0, totalOutflow = 0;
const inflows = [];
const outflows = [];
if (chainData.incoming) {
for (const t of chainData.incoming) {
const val = DataTransform.getTransferValue(t);
const sym = DataTransform.getTokenSymbol(t);
if (val <= 0) continue;
totalInflow += val;
inflows.push({
date: t.executionDate || t.blockTimestamp || '',
from: t.from,
symbol: sym,
amount: val,
});
}
}
if (chainData.outgoing) {
for (const tx of chainData.outgoing) {
if (!tx.isExecuted) continue;
if (tx.value && tx.value !== '0') {
const val = parseFloat(tx.value) / 1e18;
totalOutflow += val;
outflows.push({
date: tx.executionDate || '',
to: tx.to,
symbol: chain?.symbol || 'ETH',
amount: val,
});
}
}
}
const addresses = new Set();
inflows.forEach(t => { if (t.from) addresses.add(t.from.toLowerCase()); });
outflows.forEach(t => { if (t.to) addresses.add(t.to.toLowerCase()); });
// Render stats + chart + tables
content.innerHTML = `
<div class="stats-grid">
<div class="stat-card inflow">
<h3>Total Inflow</h3>
<div class="value">+${totalInflow.toLocaleString(undefined, {maximumFractionDigits: 2})}</div>
</div>
<div class="stat-card outflow">
<h3>Total Outflow</h3>
<div class="value">-${totalOutflow.toLocaleString(undefined, {maximumFractionDigits: 2})}</div>
</div>
<div class="stat-card neutral">
<h3>Unique Counterparties</h3>
<div class="value">${addresses.size} addresses</div>
</div>
<div class="stat-card neutral">
<h3>Chain</h3>
<div class="value">${chain?.name || 'Unknown'}</div>
</div>
</div>
<div class="legend">
<div class="legend-item"><div class="legend-color" style="background: #4ade80"></div> Inflow</div>
<div class="legend-item"><div class="legend-color" style="background: #f87171"></div> Outflow</div>
</div>
<div id="sankey-chart"></div>
<div class="tables-grid">
<div class="table-section">
<h2 class="inflow">&#8595; Incoming Transfers <span style="font-size:0.8rem;font-weight:normal;color:#888;">${inflows.length}</span></h2>
<div class="table-scroll">
<table>
<thead><tr><th>Date</th><th>From</th><th>Token</th><th>Amount</th></tr></thead>
<tbody id="inflow-table"></tbody>
</table>
</div>
</div>
<div class="table-section">
<h2 class="outflow">&#8593; Outgoing Transfers <span style="font-size:0.8rem;font-weight:normal;color:#888;">${outflows.length}</span></h2>
<div class="table-scroll">
<table>
<thead><tr><th>Date</th><th>To</th><th>Token</th><th>Amount</th></tr></thead>
<tbody id="outflow-table"></tbody>
</table>
</div>
</div>
</div>
`;
// Populate tables
const inflowTbody = document.getElementById('inflow-table');
inflows.sort((a, b) => new Date(b.date) - new Date(a.date));
inflowTbody.innerHTML = inflows.map(t => `
<tr>
<td>${t.date ? new Date(t.date).toLocaleDateString() : '-'}</td>
<td class="address"><a href="${DataTransform.explorerLink(t.from, chainId)}" target="_blank">${DataTransform.shortenAddress(t.from)}</a></td>
<td><span class="token">${t.symbol}</span></td>
<td class="amount positive">+${t.amount.toLocaleString(undefined, {maximumFractionDigits: 4})}</td>
</tr>
`).join('');
const outflowTbody = document.getElementById('outflow-table');
outflows.sort((a, b) => new Date(b.date) - new Date(a.date));
outflowTbody.innerHTML = outflows.map(t => `
<tr>
<td>${t.date ? new Date(t.date).toLocaleDateString() : '-'}</td>
<td class="address"><a href="${DataTransform.explorerLink(t.to, chainId)}" target="_blank">${DataTransform.shortenAddress(t.to)}</a></td>
<td><span class="token">${t.symbol}</span></td>
<td class="amount negative">-${t.amount.toLocaleString(undefined, {maximumFractionDigits: 4})}</td>
</tr>
`).join('');
// Draw Sankey
drawSankey(sankeyData, chainId);
}
function drawSankey(data, chainId) {
const container = document.getElementById('sankey-chart');
container.innerHTML = '';
const width = 1200;
const height = Math.max(400, data.nodes.length * 35);
const margin = { top: 20, right: 200, bottom: 20, left: 200 };
const svg = d3.select('#sankey-chart')
.append('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
const sankey = d3.sankey()
.nodeWidth(20)
.nodePadding(15)
.extent([[margin.left, margin.top], [width - margin.right, height - margin.bottom]]);
const { nodes, links } = sankey({
nodes: data.nodes.map(d => Object.assign({}, d)),
links: data.links.map(d => Object.assign({}, d))
});
// Links
svg.append('g')
.selectAll('path')
.data(links)
.join('path')
.attr('class', 'link')
.attr('d', d3.sankeyLinkHorizontal())
.attr('stroke', d => {
const sourceNode = nodes[d.source.index !== undefined ? d.source.index : d.source];
return sourceNode?.type === 'source' ? '#4ade80' : '#f87171';
})
.attr('stroke-width', d => Math.max(1, d.width))
.append('title')
.text(d => `${d.source.name} → ${d.target.name}\n${d.value.toLocaleString()} ${d.token}`);
// Nodes
const node = svg.append('g')
.selectAll('g')
.data(nodes)
.join('g');
node.append('rect')
.attr('x', d => d.x0)
.attr('y', d => d.y0)
.attr('height', d => d.y1 - d.y0)
.attr('width', d => d.x1 - d.x0)
.attr('fill', d => d.type === 'wallet' ? '#00d4ff' : d.type === 'source' ? '#4ade80' : '#f87171')
.attr('rx', 3);
node.append('text')
.attr('x', d => d.x0 < width / 2 ? d.x0 - 6 : d.x1 + 6)
.attr('y', d => (d.y1 + d.y0) / 2)
.attr('dy', '0.35em')
.attr('text-anchor', d => d.x0 < width / 2 ? 'end' : 'start')
.text(d => d.name)
.style('font-family', 'monospace')
.style('font-size', d => d.type === 'wallet' ? '14px' : '11px')
.style('font-weight', d => d.type === 'wallet' ? 'bold' : 'normal');
}
// Listen for wallet changes
window.addEventListener('wallet-changed', e => loadWallet(e.detail.address));
// Auto-load from URL
const { address } = Router.getParams();
if (address && Router.isValidAddress(address)) {
loadWallet(address);
}
</script>
</body>
</html>