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 static files
|
||||||
COPY *.html /usr/share/nginx/html/
|
COPY *.html /usr/share/nginx/html/
|
||||||
|
COPY js/ /usr/share/nginx/html/js/
|
||||||
|
|
||||||
# Custom nginx config for SPA-like behavior
|
# Custom nginx config for SPA-like behavior
|
||||||
RUN echo 'server { \
|
RUN echo 'server { \
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "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.routers.rwallet.entrypoints=web"
|
||||||
- "traefik.http.services.rwallet.loadbalancer.server.port=80"
|
- "traefik.http.services.rwallet.loadbalancer.server.port=80"
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
833
index.html
833
index.html
|
|
@ -3,165 +3,806 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
:root {
|
||||||
background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%);
|
--primary: #00d4ff;
|
||||||
color: #e0e0e0;
|
--primary-dim: rgba(0, 212, 255, 0.15);
|
||||||
min-height: 100vh;
|
--accent: #7c3aed;
|
||||||
padding: 40px 20px;
|
--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 {
|
.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;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
h1 {
|
|
||||||
text-align: center;
|
.wallet-input-group {
|
||||||
margin-bottom: 10px;
|
display: flex;
|
||||||
color: #00d4ff;
|
gap: 0;
|
||||||
font-size: 2.5rem;
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
transition: border-color 0.3s;
|
||||||
}
|
}
|
||||||
.subtitle {
|
.wallet-input-group:focus-within {
|
||||||
text-align: center;
|
border-color: rgba(0, 212, 255, 0.5);
|
||||||
color: #888;
|
box-shadow: 0 0 30px rgba(0, 212, 255, 0.08);
|
||||||
margin-bottom: 50px;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
}
|
||||||
.wallet-address {
|
|
||||||
text-align: center;
|
.wallet-input-group input {
|
||||||
font-family: monospace;
|
flex: 1;
|
||||||
color: #666;
|
padding: 16px 20px;
|
||||||
margin-bottom: 40px;
|
background: transparent;
|
||||||
font-size: 0.9rem;
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
.wallet-address a {
|
.wallet-input-group input::placeholder {
|
||||||
color: #00d4ff;
|
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;
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.wallet-address a:hover {
|
.input-hint a:hover {
|
||||||
text-decoration: underline;
|
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;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
.card {
|
|
||||||
background: rgba(255,255,255,0.03);
|
.viz-card {
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 30px;
|
padding: 32px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.card:hover {
|
.viz-card:hover {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
border-color: rgba(0, 212, 255, 0.3);
|
border-color: rgba(0, 212, 255, 0.3);
|
||||||
transform: translateY(-4px);
|
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;
|
.viz-card .viz-icon {
|
||||||
margin-bottom: 12px;
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
|
||||||
.card p {
|
|
||||||
color: #888;
|
|
||||||
line-height: 1.6;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
.card .icon {
|
|
||||||
font-size: 2.5rem;
|
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;
|
margin-top: 16px;
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
border-top: 1px solid rgba(255,255,255,0.1);
|
border-top: 1px solid var(--border);
|
||||||
}
|
|
||||||
.card .features li {
|
|
||||||
color: #666;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
list-style: none;
|
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;
|
position: relative;
|
||||||
}
|
}
|
||||||
.card .features li::before {
|
.viz-card .feature-list li::before {
|
||||||
content: "→";
|
content: "\2192";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
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 {
|
footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 60px;
|
padding: 40px 24px;
|
||||||
color: #555;
|
color: var(--text-faint);
|
||||||
font-size: 0.85rem;
|
font-size: 0.82rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
footer a {
|
footer a {
|
||||||
color: #00d4ff;
|
color: var(--primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
footer a:hover {
|
footer a:hover { text-decoration: underline; }
|
||||||
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
|
||||||
<h1>Wallet Visualizations</h1>
|
|
||||||
<p class="subtitle">Interactive multi-chain Safe wallet analytics</p>
|
|
||||||
|
|
||||||
<p class="wallet-address">
|
<!-- Hero -->
|
||||||
Analyzing: <a href="https://app.safe.global/transactions/history?safe=gno:0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1" target="_blank">0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1</a>
|
<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>
|
</p>
|
||||||
|
|
||||||
<div class="cards">
|
<!-- Wallet Input -->
|
||||||
<a href="wallet-timeline-visualization.html" class="card">
|
<div class="wallet-input-section">
|
||||||
<div class="icon">🌊</div>
|
<div class="wallet-input-group">
|
||||||
<h2>Balance River Timeline</h2>
|
<input type="text" id="wallet-input" placeholder="Enter a Safe wallet address (0x...)"
|
||||||
<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>
|
spellcheck="false" autocomplete="off" />
|
||||||
<ul class="features">
|
<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>Scroll to zoom into time periods</li>
|
||||||
<li>Horizontal scroll/drag to pan</li>
|
<li>Drag or shift-scroll to pan through history</li>
|
||||||
<li>Hover river for balance at any point</li>
|
<li>Hover the river for balance at any point</li>
|
||||||
<li>Flow width = transaction size</li>
|
<li>Flow width proportional to transaction size</li>
|
||||||
</ul>
|
</ul>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="wallet-multichain-visualization.html" class="card">
|
<a href="wallet-multichain-visualization.html" class="viz-card" id="viz-multichain">
|
||||||
<div class="icon">🔗</div>
|
<div class="viz-icon">🔗</div>
|
||||||
<h2>Multi-Chain Flow Analysis</h2>
|
<h3>Multi-Chain Flow Analysis</h3>
|
||||||
<p>Sankey diagram showing fund flows across all chains. Filter by chain to see activity on Gnosis, Ethereum, Avalanche, Optimism, and Arbitrum.</p>
|
<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="features">
|
<ul class="feature-list">
|
||||||
|
<li>Auto-detects chains with Safe deployments</li>
|
||||||
<li>Interactive chain filtering</li>
|
<li>Interactive chain filtering</li>
|
||||||
<li>Flow diagram with addresses</li>
|
<li>Flow diagram with address-level detail</li>
|
||||||
<li>Transaction tables per chain</li>
|
|
||||||
<li>Stats breakdown by direction</li>
|
<li>Stats breakdown by direction</li>
|
||||||
</ul>
|
</ul>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="wallet-visualization.html" class="card">
|
<a href="wallet-visualization.html" class="viz-card" id="viz-sankey">
|
||||||
<div class="icon">📊</div>
|
<div class="viz-icon">📊</div>
|
||||||
<h2>Gnosis Chain Overview</h2>
|
<h3>Single-Chain Sankey</h3>
|
||||||
<p>Original single-chain Sankey visualization focused on Gnosis chain transactions including WXDAI, TEC tokens, and other activity.</p>
|
<p>Classic Sankey diagram showing the complete flow of funds on a single chain. Nodes represent addresses, links represent value transferred.</p>
|
||||||
<ul class="features">
|
<ul class="feature-list">
|
||||||
<li>Simple Sankey flow diagram</li>
|
<li>Per-chain Sankey flow diagram</li>
|
||||||
<li>Address-level breakdown</li>
|
<li>Address-level fund flow breakdown</li>
|
||||||
<li>Gnosis chain focused</li>
|
<li>Chain selector for multi-chain wallets</li>
|
||||||
<li>Transaction details on hover</li>
|
<li>Transaction details on hover</li>
|
||||||
</ul>
|
</ul>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<footer>
|
<!-- Supported Chains -->
|
||||||
<p>Built with D3.js | Data from <a href="https://safe.global" target="_blank">Safe Global API</a></p>
|
<section>
|
||||||
</footer>
|
<div class="section-header">
|
||||||
|
<h2>Supported Chains</h2>
|
||||||
|
<p>rWallet auto-detects Safe deployments across these networks.</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
</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>
|
</body>
|
||||||
</html>
|
</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>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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://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="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>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
|
|
@ -15,229 +18,291 @@
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 { text-align: center; margin-bottom: 10px; color: #00d4ff; font-size: 1.8rem; }
|
||||||
text-align: center;
|
.subtitle { text-align: center; color: #888; margin-bottom: 20px; font-family: monospace; font-size: 0.9rem; }
|
||||||
margin-bottom: 10px;
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
color: #00d4ff;
|
|
||||||
font-size: 1.8rem;
|
/* Address Bar */
|
||||||
|
.address-bar { margin-bottom: 24px; }
|
||||||
|
.address-bar-inner {
|
||||||
|
display: flex; gap: 10px; align-items: center; max-width: 700px; margin: 0 auto;
|
||||||
}
|
}
|
||||||
.subtitle {
|
.back-link {
|
||||||
text-align: center;
|
color: #00d4ff; text-decoration: none; font-weight: 600; font-size: 0.9rem;
|
||||||
color: #888;
|
display: flex; align-items: center; gap: 4px; white-space: nowrap;
|
||||||
margin-bottom: 30px;
|
padding: 8px 12px; border-radius: 8px; border: 1px solid rgba(0,212,255,0.3);
|
||||||
font-family: monospace;
|
background: rgba(0,212,255,0.05); transition: all 0.2s;
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
.container {
|
.back-link:hover { background: rgba(0,212,255,0.1); }
|
||||||
max-width: 1400px;
|
.back-icon { font-size: 1.1rem; }
|
||||||
margin: 0 auto;
|
#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 {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
gap: 15px; margin-bottom: 30px;
|
||||||
gap: 15px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
}
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: rgba(255,255,255,0.05);
|
background: rgba(255,255,255,0.05); border-radius: 12px; padding: 20px;
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
}
|
}
|
||||||
.stat-card h3 {
|
.stat-card h3 { color: #888; font-size: 0.8rem; text-transform: uppercase; margin-bottom: 8px; }
|
||||||
color: #888;
|
.stat-card .value { font-size: 1.5rem; font-weight: bold; }
|
||||||
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.inflow .value { color: #4ade80; }
|
||||||
.stat-card.outflow .value { color: #f87171; }
|
.stat-card.outflow .value { color: #f87171; }
|
||||||
.stat-card.neutral .value { color: #00d4ff; }
|
.stat-card.neutral .value { color: #00d4ff; }
|
||||||
|
|
||||||
|
/* Sankey */
|
||||||
#sankey-chart {
|
#sankey-chart {
|
||||||
background: rgba(255,255,255,0.02);
|
background: rgba(255,255,255,0.02); border-radius: 12px;
|
||||||
border-radius: 12px;
|
border: 1px solid rgba(255,255,255,0.1); margin-bottom: 30px;
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
|
.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 {
|
.table-section {
|
||||||
background: rgba(255,255,255,0.03);
|
background: rgba(255,255,255,0.03); border-radius: 12px; padding: 20px;
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
}
|
}
|
||||||
.table-section h2 {
|
.table-section h2 { margin-bottom: 15px; font-size: 1.1rem; display: flex; align-items: center; gap: 10px; }
|
||||||
margin-bottom: 15px;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.table-section h2.inflow { color: #4ade80; }
|
.table-section h2.inflow { color: #4ade80; }
|
||||||
.table-section h2.outflow { color: #f87171; }
|
.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 {
|
th {
|
||||||
text-align: left;
|
text-align: left; padding: 10px 8px; border-bottom: 2px solid rgba(255,255,255,0.1);
|
||||||
padding: 10px 8px;
|
color: #888; font-weight: 600; text-transform: uppercase; font-size: 0.75rem;
|
||||||
border-bottom: 2px solid rgba(255,255,255,0.1);
|
position: sticky; top: 0; background: #1a1a2e; z-index: 10;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
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.positive { color: #4ade80; }
|
||||||
.amount.negative { color: #f87171; }
|
.amount.negative { color: #f87171; }
|
||||||
.token {
|
.token {
|
||||||
display: inline-block;
|
display: inline-block; padding: 2px 8px; border-radius: 4px;
|
||||||
padding: 2px 8px;
|
font-size: 0.75rem; font-weight: 600; background: #555;
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
.token.wxdai { background: #fbbf24; color: #000; }
|
.table-scroll { max-height: 500px; overflow-y: auto; }
|
||||||
.token.tec { background: #8b5cf6; color: #fff; }
|
|
||||||
.token.zrc { background: #06b6d4; color: #000; }
|
|
||||||
.token.spam { background: #666; color: #999; }
|
|
||||||
|
|
||||||
.legend {
|
/* Legend */
|
||||||
display: flex;
|
.legend { display: flex; justify-content: center; gap: 20px; margin-bottom: 20px; flex-wrap: wrap; font-size: 0.85rem; }
|
||||||
justify-content: center;
|
.legend-item { display: flex; align-items: center; gap: 8px; }
|
||||||
gap: 20px;
|
.legend-color { width: 16px; height: 16px; border-radius: 3px; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip {
|
/* Loading */
|
||||||
position: absolute;
|
.loading {
|
||||||
background: rgba(0,0,0,0.9);
|
text-align: center; padding: 80px 20px; color: #888;
|
||||||
color: #fff;
|
|
||||||
padding: 10px 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1000;
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
}
|
||||||
|
.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 {
|
.spam-warning {
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.3);
|
||||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
border-radius: 12px; padding: 14px 20px; margin-bottom: 24px; font-size: 0.85rem;
|
||||||
border-radius: 8px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
}
|
||||||
.spam-warning strong { color: #f87171; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Wallet Transaction Flow</h1>
|
<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 id="address-bar-container"></div>
|
||||||
|
|
||||||
|
<div id="chain-selector" class="chain-selector" style="display:none;"></div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
// Initialize address bar
|
||||||
|
Router.createAddressBar('address-bar-container');
|
||||||
|
|
||||||
|
let currentChainId = null;
|
||||||
|
let allChainData = null;
|
||||||
|
let safeAddress = '';
|
||||||
|
|
||||||
|
async function loadWallet(address) {
|
||||||
|
safeAddress = address;
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
const subtitle = document.getElementById('wallet-subtitle');
|
||||||
|
|
||||||
|
subtitle.textContent = address;
|
||||||
|
content.innerHTML = '<div class="loading"><div class="spinner"></div><p>Detecting Safe wallets across chains...</p></div>';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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('');
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check URL param or default to first chain
|
||||||
|
const params = Router.getParams();
|
||||||
|
const targetChainId = params.chainId || detected[0].chainId;
|
||||||
|
currentChainId = targetChainId;
|
||||||
|
|
||||||
|
content.innerHTML = '<div class="loading"><div class="spinner"></div><p>Fetching transaction data...</p></div>';
|
||||||
|
|
||||||
|
// Fetch data for all detected chains (we'll cache it)
|
||||||
|
allChainData = await SafeAPI.fetchAllChainsData(address, detected);
|
||||||
|
|
||||||
|
// Activate the target chain button
|
||||||
|
const targetBtn = selector.querySelector(`[data-chain-id="${targetChainId}"]`);
|
||||||
|
if (targetBtn) targetBtn.classList.add('active');
|
||||||
|
|
||||||
|
renderChain(targetChainId);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
content.innerHTML = `<div class="error"><p>Error: ${err.message}</p></div>`;
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChain(chainId) {
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
const chainData = allChainData?.get(chainId);
|
||||||
|
const chain = SafeAPI.CHAINS[chainId];
|
||||||
|
|
||||||
|
if (!chainData) {
|
||||||
|
content.innerHTML = '<div class="error"><p>No data for this chain</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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="stats-grid">
|
||||||
<div class="stat-card inflow">
|
<div class="stat-card inflow">
|
||||||
<h3>Total Inflow (WXDAI)</h3>
|
<h3>Total Inflow</h3>
|
||||||
<div class="value">+20,197 DAI</div>
|
<div class="value">+${totalInflow.toLocaleString(undefined, {maximumFractionDigits: 2})}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card outflow">
|
<div class="stat-card outflow">
|
||||||
<h3>Total Outflow (WXDAI)</h3>
|
<h3>Total Outflow</h3>
|
||||||
<div class="value">-17,697 DAI</div>
|
<div class="value">-${totalOutflow.toLocaleString(undefined, {maximumFractionDigits: 2})}</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>
|
||||||
<div class="stat-card neutral">
|
<div class="stat-card neutral">
|
||||||
<h3>Unique Counterparties</h3>
|
<h3>Unique Counterparties</h3>
|
||||||
<div class="value">8 addresses</div>
|
<div class="value">${addresses.size} addresses</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card neutral">
|
<div class="stat-card neutral">
|
||||||
<h3>Active Period</h3>
|
<h3>Chain</h3>
|
||||||
<div class="value">Mar 2023 - Dec 2023</div>
|
<div class="value">${chain?.name || 'Unknown'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 class="legend">
|
<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: #4ade80"></div> Inflow</div>
|
||||||
<div class="legend-item"><div class="legend-color" style="background: #f87171"></div> Outflow</div>
|
<div class="legend-item"><div class="legend-color" style="background: #f87171"></div> Outflow</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -246,257 +311,66 @@
|
||||||
|
|
||||||
<div class="tables-grid">
|
<div class="tables-grid">
|
||||||
<div class="table-section">
|
<div class="table-section">
|
||||||
<h2 class="inflow">↓ Incoming Transfers</h2>
|
<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>
|
<table>
|
||||||
<thead>
|
<thead><tr><th>Date</th><th>From</th><th>Token</th><th>Amount</th></tr></thead>
|
||||||
<tr>
|
<tbody id="inflow-table"></tbody>
|
||||||
<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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-section">
|
<div class="table-section">
|
||||||
<h2 class="outflow">↑ Outgoing Transfers</h2>
|
<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>
|
<table>
|
||||||
<thead>
|
<thead><tr><th>Date</th><th>To</th><th>Token</th><th>Amount</th></tr></thead>
|
||||||
<tr>
|
<tbody id="outflow-table"></tbody>
|
||||||
<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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
<script>
|
// Populate tables
|
||||||
// Sankey diagram data
|
const inflowTbody = document.getElementById('inflow-table');
|
||||||
const sankeyData = {
|
inflows.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||||
nodes: [
|
inflowTbody.innerHTML = inflows.map(t => `
|
||||||
// Inflow sources (left side)
|
<tr>
|
||||||
{ name: "0x01d9...17bD", type: "source" }, // 0 - large WXDAI source
|
<td>${t.date ? new Date(t.date).toLocaleDateString() : '-'}</td>
|
||||||
{ name: "0x5138...Ad00", type: "source" }, // 1 - TEC source
|
<td class="address"><a href="${DataTransform.explorerLink(t.from, chainId)}" target="_blank">${DataTransform.shortenAddress(t.from)}</a></td>
|
||||||
{ name: "0x9b55...0b4d", type: "source" }, // 2 - bidirectional
|
<td><span class="token">${t.symbol}</span></td>
|
||||||
{ name: "0x763d...87d4", type: "source" }, // 3 - bidirectional
|
<td class="amount positive">+${t.amount.toLocaleString(undefined, {maximumFractionDigits: 4})}</td>
|
||||||
{ name: "0xf6A7...268d", type: "source" }, // 4 - ZRC source
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
// Central wallet
|
const outflowTbody = document.getElementById('outflow-table');
|
||||||
{ name: "Safe Wallet", type: "wallet" }, // 5
|
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('');
|
||||||
|
|
||||||
// Outflow targets (right side)
|
// Draw Sankey
|
||||||
{ name: "0x9b55...0b4d (out)", type: "target" }, // 6
|
drawSankey(sankeyData, chainId);
|
||||||
{ 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" },
|
|
||||||
|
|
||||||
// Inflows (TEC)
|
function drawSankey(data, chainId) {
|
||||||
{ source: 1, target: 5, value: 9710, token: "TEC" },
|
const container = document.getElementById('sankey-chart');
|
||||||
{ source: 2, target: 5, value: 3625, token: "TEC" },
|
container.innerHTML = '';
|
||||||
{ source: 3, target: 5, value: 1, token: "TEC" },
|
|
||||||
|
|
||||||
// Inflows (ZRC)
|
|
||||||
{ source: 4, target: 5, value: 1000, token: "ZRC" },
|
|
||||||
|
|
||||||
// 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" },
|
|
||||||
|
|
||||||
// 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" },
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const tokenColors = {
|
|
||||||
"WXDAI": "#fbbf24",
|
|
||||||
"TEC": "#8b5cf6",
|
|
||||||
"ZRC": "#06b6d4"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create Sankey chart
|
|
||||||
const width = 1200;
|
const width = 1200;
|
||||||
const height = 600;
|
const height = Math.max(400, data.nodes.length * 35);
|
||||||
const margin = { top: 20, right: 200, bottom: 20, left: 200 };
|
const margin = { top: 20, right: 200, bottom: 20, left: 200 };
|
||||||
|
|
||||||
const svg = d3.select("#sankey-chart")
|
const svg = d3.select('#sankey-chart')
|
||||||
.append("svg")
|
.append('svg')
|
||||||
.attr("width", width)
|
.attr('width', width)
|
||||||
.attr("height", height)
|
.attr('height', height)
|
||||||
.attr("viewBox", `0 0 ${width} ${height}`);
|
.attr('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
|
||||||
const sankey = d3.sankey()
|
const sankey = d3.sankey()
|
||||||
.nodeWidth(20)
|
.nodeWidth(20)
|
||||||
|
|
@ -504,45 +378,58 @@
|
||||||
.extent([[margin.left, margin.top], [width - margin.right, height - margin.bottom]]);
|
.extent([[margin.left, margin.top], [width - margin.right, height - margin.bottom]]);
|
||||||
|
|
||||||
const { nodes, links } = sankey({
|
const { nodes, links } = sankey({
|
||||||
nodes: sankeyData.nodes.map(d => Object.assign({}, d)),
|
nodes: data.nodes.map(d => Object.assign({}, d)),
|
||||||
links: sankeyData.links.map(d => Object.assign({}, d))
|
links: data.links.map(d => Object.assign({}, d))
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add links
|
// Links
|
||||||
svg.append("g")
|
svg.append('g')
|
||||||
.selectAll("path")
|
.selectAll('path')
|
||||||
.data(links)
|
.data(links)
|
||||||
.join("path")
|
.join('path')
|
||||||
.attr("class", "link")
|
.attr('class', 'link')
|
||||||
.attr("d", d3.sankeyLinkHorizontal())
|
.attr('d', d3.sankeyLinkHorizontal())
|
||||||
.attr("stroke", d => tokenColors[d.token] || "#888")
|
.attr('stroke', d => {
|
||||||
.attr("stroke-width", d => Math.max(1, d.width))
|
const sourceNode = nodes[d.source.index !== undefined ? d.source.index : d.source];
|
||||||
.append("title")
|
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}`);
|
.text(d => `${d.source.name} → ${d.target.name}\n${d.value.toLocaleString()} ${d.token}`);
|
||||||
|
|
||||||
// Add nodes
|
// Nodes
|
||||||
const node = svg.append("g")
|
const node = svg.append('g')
|
||||||
.selectAll("g")
|
.selectAll('g')
|
||||||
.data(nodes)
|
.data(nodes)
|
||||||
.join("g");
|
.join('g');
|
||||||
|
|
||||||
node.append("rect")
|
node.append('rect')
|
||||||
.attr("x", d => d.x0)
|
.attr('x', d => d.x0)
|
||||||
.attr("y", d => d.y0)
|
.attr('y', d => d.y0)
|
||||||
.attr("height", d => d.y1 - d.y0)
|
.attr('height', d => d.y1 - d.y0)
|
||||||
.attr("width", d => d.x1 - d.x0)
|
.attr('width', d => d.x1 - d.x0)
|
||||||
.attr("fill", d => d.type === "wallet" ? "#00d4ff" : d.type === "source" ? "#4ade80" : "#f87171")
|
.attr('fill', d => d.type === 'wallet' ? '#00d4ff' : d.type === 'source' ? '#4ade80' : '#f87171')
|
||||||
.attr("rx", 3);
|
.attr('rx', 3);
|
||||||
|
|
||||||
node.append("text")
|
node.append('text')
|
||||||
.attr("x", d => d.x0 < width / 2 ? d.x0 - 6 : d.x1 + 6)
|
.attr('x', d => d.x0 < width / 2 ? d.x0 - 6 : d.x1 + 6)
|
||||||
.attr("y", d => (d.y1 + d.y0) / 2)
|
.attr('y', d => (d.y1 + d.y0) / 2)
|
||||||
.attr("dy", "0.35em")
|
.attr('dy', '0.35em')
|
||||||
.attr("text-anchor", d => d.x0 < width / 2 ? "end" : "start")
|
.attr('text-anchor', d => d.x0 < width / 2 ? 'end' : 'start')
|
||||||
.text(d => d.name)
|
.text(d => d.name)
|
||||||
.style("font-family", "monospace")
|
.style('font-family', 'monospace')
|
||||||
.style("font-size", d => d.type === "wallet" ? "14px" : "11px")
|
.style('font-size', d => d.type === 'wallet' ? '14px' : '11px')
|
||||||
.style("font-weight", d => d.type === "wallet" ? "bold" : "normal");
|
.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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue