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:
parent
995a54565a
commit
d0c75aba8f
|
|
@ -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 { \
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
859
index.html
859
index.html
|
|
@ -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 — 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">🌐</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">🔍</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">📊</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 — 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 & Verify</h3>
|
||||
<p>Every view has a shareable deep-link. Anyone can verify the data independently — 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">🌊</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">🔗</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">📊</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 — 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">
|
||||
🌊 Balance River
|
||||
</a>
|
||||
<a href="wallet-multichain-visualization.html?address=0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1" class="btn btn-outline">
|
||||
🔗 Multi-Chain Flow
|
||||
</a>
|
||||
<a href="wallet-visualization.html?address=0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1&chainId=100" class="btn btn-outline">
|
||||
📊 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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
})();
|
||||
|
|
@ -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">←</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,
|
||||
};
|
||||
})();
|
||||
|
|
@ -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
|
|
@ -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">↓ 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">↑ 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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue