409 lines
22 KiB
HTML
409 lines
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Multi-Chain Flow | rWallet.online</title>
|
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
<script src="js/safe-api.js?v=2"></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, #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: 12px 24px; border-radius: 12px; 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: 10px; font-size: 0.95rem; 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 .logo {
|
|
width: 28px; height: 28px; border-radius: 50%; display: flex;
|
|
align-items: center; justify-content: center; font-weight: bold; font-size: 0.8rem;
|
|
}
|
|
.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: 20px; font-size: 1.2rem; color: #00d4ff; }
|
|
#flow-chart { width: 100%; min-height: 400px; }
|
|
|
|
/* 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; }
|
|
|
|
/* 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: 6px; }
|
|
.legend-color { width: 14px; height: 14px; border-radius: 3px; }
|
|
|
|
.warning-box { 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; }
|
|
|
|
/* 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="logo" style="background:linear-gradient(135deg,#00d4ff,#8b5cf6)">∀</span>
|
|
All Chains
|
|
<span class="count">${chainStats.all?.transfers || 0}</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="logo" style="background:${chain.color}">${chain.name[0]}</span>
|
|
${chain.name}
|
|
<span class="count">${stats?.transfers || 0}</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="warning-box">
|
|
<span style="font-size:1.2rem;">⚠️</span>
|
|
<span><strong>Spam filtered:</strong> This analysis uses Safe's trusted token list and excludes known spam tokens.</span>
|
|
</div>
|
|
|
|
<div class="legend" id="chain-legend"></div>
|
|
|
|
<div class="flow-section">
|
|
<h2>Transaction Flow Diagram</h2>
|
|
<div id="flow-chart"></div>
|
|
</div>
|
|
|
|
<div class="tables-section">
|
|
<div class="table-panel">
|
|
<h3 class="inflow">↓ 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">↑ 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>
|
|
`;
|
|
|
|
// Build legend
|
|
const legend = document.getElementById('chain-legend');
|
|
legend.innerHTML = detectedChains.map(({ chain }) =>
|
|
`<div class="legend-item"><div class="legend-color" style="background:${chain.color}"></div> ${chain.name}</div>`
|
|
).join('');
|
|
|
|
// 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;
|
|
}
|
|
|
|
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}`);
|
|
|
|
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
|
|
svg.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);
|
|
svg.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');
|
|
svg.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);
|
|
svg.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);
|
|
|
|
svg.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);
|
|
svg.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);
|
|
svg.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);
|
|
svg.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);
|
|
|
|
svg.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);
|
|
svg.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);
|
|
svg.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);
|
|
svg.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`);
|
|
}
|
|
|
|
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>
|