rwallet-online/wallet-multichain-visualiza...

430 lines
23 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>💰</text></svg>">
<title>Multi-Chain Flow | rWallet.online</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="js/safe-api.js?v=4"></script>
<script src="js/data-transform.js?v=2"></script>
<script src="js/router.js?v=5"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%);
color: #e0e0e0; min-height: 100vh; padding: 20px;
}
h1 { text-align: center; margin-bottom: 10px; color: #00d4ff; font-size: 2rem; }
.subtitle { text-align: center; color: #888; margin-bottom: 20px; font-family: monospace; font-size: 0.9rem; }
.container { max-width: 1600px; 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;
}
#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;
}
#load-wallet-btn:hover { background: #00b8d9; }
/* Chain Selector */
.chain-selector { display: flex; justify-content: center; gap: 12px; margin-bottom: 30px; flex-wrap: wrap; }
.chain-btn {
padding: 10px 18px; border-radius: 10px; border: 2px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.03); cursor: pointer; transition: all 0.3s;
display: flex; align-items: center; gap: 8px; font-size: 0.9rem; color: #e0e0e0;
}
.chain-btn:hover { background: rgba(255,255,255,0.08); transform: translateY(-2px); }
.chain-btn.active { border-color: var(--chain-color, #00d4ff); background: rgba(255,255,255,0.1); box-shadow: 0 0 20px rgba(var(--chain-rgb, 0,212,255), 0.3); }
.chain-btn .chain-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.chain-btn .count { background: rgba(0,0,0,0.3); padding: 2px 10px; border-radius: 12px; font-size: 0.8rem; color: #888; }
.chain-btn.active .count { background: var(--chain-color, #00d4ff); color: #000; }
/* Stats */
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; margin-bottom: 30px; }
.stat-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 16px; border: 1px solid rgba(255,255,255,0.1); text-align: center; }
.stat-card h4 { color: #888; font-size: 0.7rem; text-transform: uppercase; margin-bottom: 8px; }
.stat-card .value { font-size: 1.4rem; font-weight: bold; }
.stat-card .value.inflow { color: #4ade80; }
.stat-card .value.outflow { color: #f87171; }
.stat-card .value.neutral { color: #00d4ff; }
/* Flow */
.flow-section { background: rgba(255,255,255,0.02); border-radius: 16px; padding: 24px; margin-bottom: 30px; border: 1px solid rgba(255,255,255,0.1); }
.flow-section h2 { margin-bottom: 12px; font-size: 1.2rem; color: #00d4ff; }
#flow-chart { width: 100%; min-height: 400px; overflow: hidden; cursor: grab; }
#flow-chart svg { display: block; }
.zoom-hint { text-align: center; font-size: 0.8rem; color: #555; margin-bottom: 12px; }
.zoom-hint button {
padding: 4px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.05); color: #ccc; cursor: pointer; font-size: 0.8rem;
margin-left: 12px;
}
.zoom-hint button:hover { background: rgba(255,255,255,0.1); }
/* Tables */
.tables-section { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 30px; }
@media (max-width: 1200px) { .tables-section { grid-template-columns: 1fr; } }
.table-panel { background: rgba(255,255,255,0.02); border-radius: 16px; padding: 20px; border: 1px solid rgba(255,255,255,0.1); }
.table-panel h3 { margin-bottom: 16px; font-size: 1.1rem; display: flex; align-items: center; gap: 10px; }
.table-panel h3.inflow { color: #4ade80; }
.table-panel h3.outflow { color: #f87171; }
.table-panel h3 .count { background: rgba(255,255,255,0.1); padding: 2px 10px; border-radius: 10px; font-size: 0.8rem; font-weight: normal; }
.table-scroll { max-height: 500px; overflow-y: auto; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th { text-align: left; padding: 12px 8px; border-bottom: 2px solid rgba(255,255,255,0.1); color: #888; font-weight: 600; text-transform: uppercase; font-size: 0.7rem; position: sticky; top: 0; background: #1a1a2e; z-index: 10; }
td { padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.05); vertical-align: middle; }
tr:hover td { background: rgba(255,255,255,0.03); }
tr.hidden { display: none; }
.chain-badge { font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; font-weight: 600; text-transform: uppercase; color: #fff; }
.address-cell { font-family: monospace; font-size: 0.8rem; }
.address-cell a { color: #00d4ff; text-decoration: none; }
.address-cell a:hover { text-decoration: underline; }
.amount { font-weight: 600; text-align: right; font-family: monospace; }
.amount.positive { color: #4ade80; }
.amount.negative { color: #f87171; }
.token-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; background: #555; }
.warning-box { background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px; padding: 10px 16px; font-size: 0.85rem; display: flex; align-items: center; gap: 10px; }
/* 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; }
</style>
</head>
<body>
<div class="container">
<h1>Multi-Chain Wallet Flow</h1>
<p class="subtitle" id="wallet-subtitle">Enter a Safe wallet address to visualize</p>
<div id="address-bar-container"></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" style="color:#00d4ff;">TEC Commons Fund</a></p>
</div>
</div>
</div>
<script>
Router.createAddressBar('address-bar-container');
let chainStats = {};
let flowData = {};
let allTransfers = { incoming: [], outgoing: [] };
let detectedChains = [];
let currentChain = 'all';
async function loadWallet(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 {
detectedChains = await SafeAPI.detectSafeChains(address);
if (detectedChains.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;
}
content.innerHTML = '<div class="loading"><div class="spinner"></div><p>Fetching data from ' + detectedChains.length + ' chain(s)...</p></div>';
const chainDataMap = await SafeAPI.fetchAllChainsData(address, detectedChains);
const transformed = DataTransform.transformToMultichainData(chainDataMap, address);
chainStats = transformed.chainStats;
flowData = transformed.flowData;
allTransfers = transformed.allTransfers;
renderUI();
} catch (err) {
content.innerHTML = `<div class="error"><p>Error: ${err.message}</p></div>`;
console.error(err);
}
}
function renderUI() {
const content = document.getElementById('content');
// Build chain buttons
const chainButtons = [
`<button class="chain-btn active" data-chain="all" style="--chain-color:#00d4ff;--chain-rgb:0,212,255">
<span class="chain-dot" style="background:linear-gradient(135deg,#00d4ff,#8b5cf6)"></span>
All Chains
<span class="count">${chainStats.all?.transfers || 0} tx</span>
</button>`
];
for (const { chainId, chain } of detectedChains) {
const name = chain.name.toLowerCase();
const stats = chainStats[name];
const rgb = hexToRgb(chain.color);
chainButtons.push(
`<button class="chain-btn" data-chain="${name}" style="--chain-color:${chain.color};--chain-rgb:${rgb}">
<span class="chain-dot" style="background:${chain.color}"></span>
${chain.name}
<span class="count">${stats?.transfers || 0} tx</span>
</button>`
);
}
content.innerHTML = `
<div class="chain-selector">${chainButtons.join('')}</div>
<div class="stats-row">
<div class="stat-card"><h4>Total Transfers</h4><div class="value neutral" id="stat-transfers">0</div></div>
<div class="stat-card"><h4>Total Inflow</h4><div class="value inflow" id="stat-inflow">$0</div></div>
<div class="stat-card"><h4>Total Outflow</h4><div class="value outflow" id="stat-outflow">$0</div></div>
<div class="stat-card"><h4>Unique Addresses</h4><div class="value neutral" id="stat-addresses">0</div></div>
<div class="stat-card"><h4>Active Period</h4><div class="value neutral" id="stat-period">-</div></div>
</div>
<div class="flow-section">
<h2>Transaction Flow Diagram</h2>
<div class="zoom-hint">Scroll to zoom &bull; Drag to pan <button id="reset-flow-zoom">Reset View</button></div>
<div id="flow-chart"></div>
</div>
<div class="tables-section">
<div class="table-panel">
<h3 class="inflow">&#8595; Incoming Transfers <span class="count" id="inflow-count">0</span></h3>
<div class="table-scroll">
<table><thead><tr><th>Chain</th><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-panel">
<h3 class="outflow">&#8593; Outgoing Transfers <span class="count" id="outflow-count">0</span></h3>
<div class="table-scroll">
<table><thead><tr><th>Chain</th><th>Date</th><th>To</th><th>Token</th><th>Amount</th></tr></thead>
<tbody id="outflow-table"></tbody></table>
</div>
</div>
</div>
<div class="warning-box" style="opacity:0.5;">
<span style="font-size:0.85rem;">&#9888;&#65039;</span>
<span style="font-size:0.75rem; color:#888;">Spam filtered &mdash; uses Safe's trusted token list, excludes known spam tokens.</span>
</div>
`;
// Bind chain selector
content.querySelectorAll('.chain-btn').forEach(btn => {
btn.addEventListener('click', () => {
content.querySelectorAll('.chain-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentChain = btn.dataset.chain;
updateView();
});
});
currentChain = 'all';
updateView();
}
function updateView() {
updateStats(currentChain);
filterTables(currentChain);
drawFlowChart(currentChain);
}
function updateStats(chain) {
const stats = chainStats[chain] || chainStats['all'];
if (!stats) return;
document.getElementById('stat-transfers').textContent = stats.transfers;
document.getElementById('stat-inflow').textContent = stats.inflow;
document.getElementById('stat-outflow').textContent = stats.outflow;
document.getElementById('stat-addresses').textContent = stats.addresses;
document.getElementById('stat-period').textContent = stats.period;
}
function filterTables(chain) {
const inflowTbody = document.getElementById('inflow-table');
const outflowTbody = document.getElementById('outflow-table');
const filteredIn = chain === 'all' ? allTransfers.incoming : allTransfers.incoming.filter(t => t.chainName === chain);
const filteredOut = chain === 'all' ? allTransfers.outgoing : allTransfers.outgoing.filter(t => t.chainName === chain);
inflowTbody.innerHTML = filteredIn.slice(0, 200).map(t => {
const chainColor = SafeAPI.CHAINS[t.chainId]?.color || '#555';
const chainLabel = SafeAPI.CHAINS[t.chainId]?.name?.substring(0, 3).toUpperCase() || '?';
const explorerUrl = DataTransform.explorerLink(t.from, t.chainId);
return `<tr>
<td><span class="chain-badge" style="background:${chainColor}">${chainLabel}</span></td>
<td>${t.date ? new Date(t.date).toLocaleDateString() : '-'}</td>
<td class="address-cell"><a href="${explorerUrl}" target="_blank">${t.fromShort}</a></td>
<td><span class="token-badge">${t.token}</span></td>
<td class="amount positive">+${t.amount.toLocaleString(undefined, {maximumFractionDigits: 4})}</td>
</tr>`;
}).join('');
outflowTbody.innerHTML = filteredOut.slice(0, 200).map(t => {
const chainColor = SafeAPI.CHAINS[t.chainId]?.color || '#555';
const chainLabel = SafeAPI.CHAINS[t.chainId]?.name?.substring(0, 3).toUpperCase() || '?';
const explorerUrl = DataTransform.explorerLink(t.to, t.chainId);
return `<tr>
<td><span class="chain-badge" style="background:${chainColor}">${chainLabel}</span></td>
<td>${t.date ? new Date(t.date).toLocaleDateString() : '-'}</td>
<td class="address-cell"><a href="${explorerUrl}" target="_blank">${t.toShort}</a></td>
<td><span class="token-badge">${t.token}</span></td>
<td class="amount negative">-${t.amount.toLocaleString(undefined, {maximumFractionDigits: 4})}</td>
</tr>`;
}).join('');
document.getElementById('inflow-count').textContent = filteredIn.length;
document.getElementById('outflow-count').textContent = filteredOut.length;
}
let flowZoom = null;
let flowSvg = null;
function drawFlowChart(chain) {
const container = document.getElementById('flow-chart');
if (!container) return;
container.innerHTML = '';
const flows = flowData[chain] || flowData['all'] || [];
if (flows.length === 0) {
container.innerHTML = '<p style="text-align:center;color:#666;padding:40px;">No flow data for this chain</p>';
return;
}
const w = container.clientWidth || 1000;
const h = 400;
const svg = d3.select('#flow-chart').append('svg')
.attr('width', '100%').attr('height', h).attr('viewBox', `0 0 ${w} ${h}`)
.style('cursor', 'grab');
flowSvg = svg;
// Zoom group wraps all content
const g = svg.append('g');
const inflows = flows.filter(f => f.to === 'Safe Wallet');
const outflows = flows.filter(f => f.from === 'Safe Wallet');
const walletX = w / 2, walletY = h / 2;
// Central wallet
g.append('rect').attr('x', walletX - 70).attr('y', walletY - 35).attr('width', 140).attr('height', 70)
.attr('rx', 12).attr('fill', '#00d4ff').attr('opacity', 0.9);
g.append('text').attr('x', walletX).attr('y', walletY - 8).attr('text-anchor', 'middle')
.attr('fill', '#000').attr('font-weight', 'bold').attr('font-size', '13px').text('Safe Wallet');
g.append('text').attr('x', walletX).attr('y', walletY + 12).attr('text-anchor', 'middle')
.attr('fill', '#000').attr('font-family', 'monospace').attr('font-size', '10px')
.text(DataTransform.shortenAddress(Router.getParams().address));
// Get chain color
function getFlowColor(chainName) {
for (const [, c] of Object.entries(SafeAPI.CHAINS)) {
if (c.name.toLowerCase() === chainName) return c.color;
}
return '#00d4ff';
}
// Inflows
const inflowSpacing = h / (inflows.length + 1);
inflows.forEach((flow, i) => {
const y = inflowSpacing * (i + 1);
const sourceX = 120;
const color = getFlowColor(flow.chain);
const path = d3.path();
path.moveTo(sourceX + 60, y);
path.bezierCurveTo(sourceX + 150, y, walletX - 150, walletY, walletX - 70, walletY);
g.append('path').attr('d', path.toString()).attr('fill', 'none')
.attr('stroke', '#4ade80').attr('stroke-width', Math.max(2, Math.log(flow.value + 1) * 1.2)).attr('stroke-opacity', 0.6);
g.append('rect').attr('x', sourceX - 60).attr('y', y - 14).attr('width', 120).attr('height', 28)
.attr('rx', 6).attr('fill', color).attr('opacity', 0.3).attr('stroke', color);
g.append('text').attr('x', sourceX).attr('y', y + 4).attr('text-anchor', 'middle')
.attr('fill', '#e0e0e0').attr('font-family', 'monospace').attr('font-size', '10px').text(flow.from);
g.append('text').attr('x', sourceX + 100).attr('y', y - 20)
.attr('fill', '#4ade80').attr('font-size', '9px').text(`+${flow.value.toLocaleString()} ${flow.token}`);
});
// Outflows
const outflowSpacing = h / (outflows.length + 1);
outflows.forEach((flow, i) => {
const y = outflowSpacing * (i + 1);
const targetX = w - 120;
const color = getFlowColor(flow.chain);
const path = d3.path();
path.moveTo(walletX + 70, walletY);
path.bezierCurveTo(walletX + 150, walletY, targetX - 150, y, targetX - 60, y);
g.append('path').attr('d', path.toString()).attr('fill', 'none')
.attr('stroke', '#f87171').attr('stroke-width', Math.max(2, Math.log(flow.value + 1) * 1.2)).attr('stroke-opacity', 0.6);
g.append('rect').attr('x', targetX - 60).attr('y', y - 14).attr('width', 120).attr('height', 28)
.attr('rx', 6).attr('fill', color).attr('opacity', 0.3).attr('stroke', color);
g.append('text').attr('x', targetX).attr('y', y + 4).attr('text-anchor', 'middle')
.attr('fill', '#e0e0e0').attr('font-family', 'monospace').attr('font-size', '10px').text(flow.to);
g.append('text').attr('x', targetX - 100).attr('y', y - 20)
.attr('fill', '#f87171').attr('font-size', '9px').text(`-${flow.value.toLocaleString()} ${flow.token}`);
});
const chainLabel = chain === 'all' ? 'All Chains' : chain.charAt(0).toUpperCase() + chain.slice(1);
g.append('text').attr('x', w / 2).attr('y', 25).attr('text-anchor', 'middle')
.attr('fill', '#888').attr('font-size', '12px').text(`${chainLabel} - Major Fund Flows`);
// Zoom behavior
flowZoom = d3.zoom()
.scaleExtent([0.3, 5])
.on('zoom', (event) => {
g.attr('transform', event.transform);
})
.on('start', () => svg.style('cursor', 'grabbing'))
.on('end', () => svg.style('cursor', 'grab'));
svg.call(flowZoom);
// Reset zoom button
const resetBtn = document.getElementById('reset-flow-zoom');
if (resetBtn) {
resetBtn.onclick = () => {
svg.transition().duration(500).call(flowZoom.transform, d3.zoomIdentity);
};
}
}
function hexToRgb(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `${r},${g},${b}`;
}
// Listen for wallet changes
window.addEventListener('wallet-changed', e => loadWallet(e.detail.address));
window.addEventListener('resize', () => drawFlowChart(currentChain));
// Auto-load from URL
const { address } = Router.getParams();
if (address && Router.isValidAddress(address)) loadWallet(address);
</script>
</body>
</html>