rwallet-online/wallet-visualization.html

436 lines
20 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Single-Chain Flow | rWallet.online</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3-sankey@0.12.3/dist/d3-sankey.min.js"></script>
<script src="js/safe-api.js?v=3"></script>
<script src="js/data-transform.js?v=2"></script>
<script src="js/router.js?v=2"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #e0e0e0;
min-height: 100vh;
padding: 20px;
}
h1 { text-align: center; margin-bottom: 10px; color: #00d4ff; font-size: 1.8rem; }
.subtitle { text-align: center; color: #888; margin-bottom: 20px; font-family: monospace; font-size: 0.9rem; }
.container { max-width: 1400px; margin: 0 auto; }
/* Address Bar */
.address-bar { margin-bottom: 24px; }
.address-bar-inner {
display: flex; gap: 10px; align-items: center; max-width: 700px; margin: 0 auto;
}
.back-link {
color: #00d4ff; text-decoration: none; font-weight: 600; font-size: 0.9rem;
display: flex; align-items: center; gap: 4px; white-space: nowrap;
padding: 8px 12px; border-radius: 8px; border: 1px solid rgba(0,212,255,0.3);
background: rgba(0,212,255,0.05); transition: all 0.2s;
}
.back-link:hover { background: rgba(0,212,255,0.1); }
.back-icon { font-size: 1.1rem; }
#wallet-input {
flex: 1; padding: 10px 16px; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.05);
color: #e0e0e0; font-family: monospace; font-size: 0.9rem; outline: none;
transition: border-color 0.3s;
}
#wallet-input:focus { border-color: #00d4ff; }
#load-wallet-btn {
padding: 10px 20px; border-radius: 8px; border: none;
background: #00d4ff; color: #000; font-weight: 600; cursor: pointer;
transition: background 0.2s;
}
#load-wallet-btn:hover { background: #00b8d9; }
/* Chain Selector */
.chain-selector {
display: flex; justify-content: center; gap: 10px; margin-bottom: 24px; flex-wrap: wrap;
}
.chain-btn {
padding: 8px 16px; border-radius: 8px;
border: 2px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03);
cursor: pointer; transition: all 0.3s; color: #e0e0e0; font-size: 0.85rem;
}
.chain-btn:hover { background: rgba(255,255,255,0.08); }
.chain-btn.active { border-color: var(--chain-color, #00d4ff); background: rgba(255,255,255,0.1); }
/* Stats */
.stats-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px; margin-bottom: 30px;
}
.stat-card {
background: rgba(255,255,255,0.05); border-radius: 12px; padding: 20px;
border: 1px solid rgba(255,255,255,0.1);
}
.stat-card h3 { color: #888; font-size: 0.8rem; text-transform: uppercase; margin-bottom: 8px; }
.stat-card .value { font-size: 1.5rem; font-weight: bold; }
.stat-card.inflow .value { color: #4ade80; }
.stat-card.outflow .value { color: #f87171; }
.stat-card.neutral .value { color: #00d4ff; }
/* Sankey */
#sankey-chart {
background: rgba(255,255,255,0.02); border-radius: 12px;
border: 1px solid rgba(255,255,255,0.1); margin-bottom: 30px;
}
.node rect { stroke: #333; stroke-width: 1px; }
.node text { font-size: 11px; fill: #e0e0e0; }
.link { fill: none; stroke-opacity: 0.4; }
.link:hover { stroke-opacity: 0.7; }
/* Tables */
.tables-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 1000px) { .tables-grid { grid-template-columns: 1fr; } }
.table-section {
background: rgba(255,255,255,0.03); border-radius: 12px; padding: 20px;
border: 1px solid rgba(255,255,255,0.1);
}
.table-section h2 { margin-bottom: 15px; font-size: 1.1rem; display: flex; align-items: center; gap: 10px; }
.table-section h2.inflow { color: #4ade80; }
.table-section h2.outflow { color: #f87171; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th {
text-align: left; padding: 10px 8px; border-bottom: 2px solid rgba(255,255,255,0.1);
color: #888; font-weight: 600; text-transform: uppercase; font-size: 0.75rem;
position: sticky; top: 0; background: #1a1a2e; z-index: 10;
}
td { padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.05); }
tr:hover td { background: rgba(255,255,255,0.03); }
.address { font-family: monospace; font-size: 0.8rem; color: #00d4ff; }
.address a { color: #00d4ff; text-decoration: none; }
.address a:hover { text-decoration: underline; }
.amount { font-weight: 600; text-align: right; }
.amount.positive { color: #4ade80; }
.amount.negative { color: #f87171; }
.token {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 0.75rem; font-weight: 600; background: #555;
}
.table-scroll { max-height: 500px; overflow-y: auto; }
/* Legend */
.legend { display: flex; justify-content: center; gap: 20px; margin-bottom: 20px; flex-wrap: wrap; font-size: 0.85rem; }
.legend-item { display: flex; align-items: center; gap: 8px; }
.legend-color { width: 16px; height: 16px; border-radius: 3px; }
/* Loading */
.loading {
text-align: center; padding: 80px 20px; color: #888;
}
.loading .spinner {
width: 40px; height: 40px; border: 3px solid rgba(0,212,255,0.2);
border-top-color: #00d4ff; border-radius: 50%;
animation: spin 0.8s linear infinite; margin: 0 auto 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.error { text-align: center; padding: 60px 20px; color: #f87171; }
.empty { text-align: center; padding: 60px 20px; color: #666; }
.spam-warning {
background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 12px; padding: 14px 20px; margin-bottom: 24px; font-size: 0.85rem;
display: flex; align-items: center; gap: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1>Wallet Transaction Flow</h1>
<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="stat-card inflow">
<h3>Total Inflow</h3>
<div class="value">+${totalInflow.toLocaleString(undefined, {maximumFractionDigits: 2})}</div>
</div>
<div class="stat-card outflow">
<h3>Total Outflow</h3>
<div class="value">-${totalOutflow.toLocaleString(undefined, {maximumFractionDigits: 2})}</div>
</div>
<div class="stat-card neutral">
<h3>Unique Counterparties</h3>
<div class="value">${addresses.size} addresses</div>
</div>
<div class="stat-card neutral">
<h3>Chain</h3>
<div class="value">${chain?.name || 'Unknown'}</div>
</div>
</div>
<div class="legend">
<div class="legend-item"><div class="legend-color" style="background: #4ade80"></div> Inflow</div>
<div class="legend-item"><div class="legend-color" style="background: #f87171"></div> Outflow</div>
</div>
<div id="sankey-chart"></div>
<div class="tables-grid">
<div class="table-section">
<h2 class="inflow">&#8595; Incoming Transfers <span style="font-size:0.8rem;font-weight:normal;color:#888;">${inflows.length}</span></h2>
<div class="table-scroll">
<table>
<thead><tr><th>Date</th><th>From</th><th>Token</th><th>Amount</th></tr></thead>
<tbody id="inflow-table"></tbody>
</table>
</div>
</div>
<div class="table-section">
<h2 class="outflow">&#8593; Outgoing Transfers <span style="font-size:0.8rem;font-weight:normal;color:#888;">${outflows.length}</span></h2>
<div class="table-scroll">
<table>
<thead><tr><th>Date</th><th>To</th><th>Token</th><th>Amount</th></tr></thead>
<tbody id="outflow-table"></tbody>
</table>
</div>
</div>
</div>
`;
// Populate tables
const inflowTbody = document.getElementById('inflow-table');
inflows.sort((a, b) => new Date(b.date) - new Date(a.date));
inflowTbody.innerHTML = inflows.map(t => `
<tr>
<td>${t.date ? new Date(t.date).toLocaleDateString() : '-'}</td>
<td class="address"><a href="${DataTransform.explorerLink(t.from, chainId)}" target="_blank">${DataTransform.shortenAddress(t.from)}</a></td>
<td><span class="token">${t.symbol}</span></td>
<td class="amount positive">+${t.amount.toLocaleString(undefined, {maximumFractionDigits: 4})}</td>
</tr>
`).join('');
const outflowTbody = document.getElementById('outflow-table');
outflows.sort((a, b) => new Date(b.date) - new Date(a.date));
outflowTbody.innerHTML = outflows.map(t => `
<tr>
<td>${t.date ? new Date(t.date).toLocaleDateString() : '-'}</td>
<td class="address"><a href="${DataTransform.explorerLink(t.to, chainId)}" target="_blank">${DataTransform.shortenAddress(t.to)}</a></td>
<td><span class="token">${t.symbol}</span></td>
<td class="amount negative">-${t.amount.toLocaleString(undefined, {maximumFractionDigits: 4})}</td>
</tr>
`).join('');
// Draw Sankey
drawSankey(sankeyData, chainId);
}
function drawSankey(data, chainId) {
const container = document.getElementById('sankey-chart');
container.innerHTML = '';
const width = 1200;
const height = Math.max(400, data.nodes.length * 35);
const margin = { top: 20, right: 200, bottom: 20, left: 200 };
const svg = d3.select('#sankey-chart')
.append('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
const sankey = d3.sankey()
.nodeWidth(20)
.nodePadding(15)
.extent([[margin.left, margin.top], [width - margin.right, height - margin.bottom]]);
const { nodes, links } = sankey({
nodes: data.nodes.map(d => Object.assign({}, d)),
links: data.links.map(d => Object.assign({}, d))
});
// Links
svg.append('g')
.selectAll('path')
.data(links)
.join('path')
.attr('class', 'link')
.attr('d', d3.sankeyLinkHorizontal())
.attr('stroke', d => {
const sourceNode = nodes[d.source.index !== undefined ? d.source.index : d.source];
return sourceNode?.type === 'source' ? '#4ade80' : '#f87171';
})
.attr('stroke-width', d => Math.max(1, d.width))
.append('title')
.text(d => `${d.source.name}${d.target.name}\n${d.value.toLocaleString()} ${d.token}`);
// Nodes
const node = svg.append('g')
.selectAll('g')
.data(nodes)
.join('g');
node.append('rect')
.attr('x', d => d.x0)
.attr('y', d => d.y0)
.attr('height', d => d.y1 - d.y0)
.attr('width', d => d.x1 - d.x0)
.attr('fill', d => d.type === 'wallet' ? '#00d4ff' : d.type === 'source' ? '#4ade80' : '#f87171')
.attr('rx', 3);
node.append('text')
.attr('x', d => d.x0 < width / 2 ? d.x0 - 6 : d.x1 + 6)
.attr('y', d => (d.y1 + d.y0) / 2)
.attr('dy', '0.35em')
.attr('text-anchor', d => d.x0 < width / 2 ? 'end' : 'start')
.text(d => d.name)
.style('font-family', 'monospace')
.style('font-size', d => d.type === 'wallet' ? '14px' : '11px')
.style('font-weight', d => d.type === 'wallet' ? 'bold' : 'normal');
}
// Listen for wallet changes
window.addEventListener('wallet-changed', e => loadWallet(e.detail.address));
// Auto-load from URL
const { address } = Router.getParams();
if (address && Router.isValidAddress(address)) {
loadWallet(address);
}
</script>
</body>
</html>