diff --git a/Dockerfile b/Dockerfile index eb9dbf3..945db33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,7 @@ FROM nginx:alpine # Copy static files COPY *.html /usr/share/nginx/html/ +COPY js/ /usr/share/nginx/html/js/ # Custom nginx config for SPA-like behavior RUN echo 'server { \ diff --git a/docker-compose.yml b/docker-compose.yml index 4010dc1..2ced777 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: restart: unless-stopped labels: - "traefik.enable=true" - - "traefik.http.routers.rwallet.rule=Host(`wallets.bondingcurve.tech`)" + - "traefik.http.routers.rwallet.rule=Host(`rwallet.online`) || Host(`www.rwallet.online`) || Host(`wallets.bondingcurve.tech`)" - "traefik.http.routers.rwallet.entrypoints=web" - "traefik.http.services.rwallet.loadbalancer.server.port=80" networks: diff --git a/index.html b/index.html index 0208f7a..b364d4b 100644 --- a/index.html +++ b/index.html @@ -3,165 +3,806 @@ - Wallet Visualizations | wallets.bondingcurve.tech + rWallet.online | Democratic Wallet Management for Communities + -
-

Wallet Visualizations

-

Interactive multi-chain Safe wallet analytics

-

- Analyzing: 0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1 + +

+
Part of the rSpace Ecosystem
+

Democratic Wallet
Management for Communities

+

+ Interactive visualizations for group treasury management. + Explore any Safe multi-sig wallet with balance rivers, Sankey flow diagrams, + and multi-chain analysis — all from live on-chain data.

-
+ +
+ + +
+
+
ELI5
+

rWallet in 30 Seconds

+

+ A multi-chain, + transparent, + visual + wallet explorer built for community treasuries. +

+
+ +
+
+
+
🌐
+

Multi-Chain

+
+

+ Automatically detects your Safe across Ethereum, Gnosis, Polygon, Base, Optimism, Arbitrum, and Avalanche. + See all activity in one place. +

+
+ +
+
+
🔍
+

Transparent

+
+

+ Real-time data fetched directly from the Safe Transaction Service API. No intermediaries, nothing hidden. + Verify every transaction yourself. +

+
+ +
+
+
📊
+

Visual

+
+

+ Interactive D3.js visualizations: Balance River timelines, Sankey flow diagrams, and cross-chain analysis. + Understand flows at a glance. +

+
+
+
+ + +
+
+
How It Works
+

From Address to Insight

+
+ +
+
+
1
+

Enter Address

+

Paste any Safe multi-sig wallet address. rWallet checks all supported chains in parallel.

+
+
+
2
+

Fetch Live Data

+

Transactions, balances, and transfers are pulled directly from the Safe Global API — no backend needed.

+
+
+
3
+

Visualize

+

Choose from three interactive visualization modes to understand fund flows, balances over time, and cross-chain activity.

+
+
+
4
+

Share & Verify

+

Every view has a shareable deep-link. Anyone can verify the data independently — full transparency.

+
+
+
+ + +
+
+

Three Ways to Explore

+

Each visualization reveals different aspects of your wallet's activity.

+
+ + +
+ + +
+
+

Supported Chains

+

rWallet auto-detects Safe deployments across these networks.

+
+ +
+
+ + Ethereum +
+
+ + Optimism +
+
+ + Gnosis +
+
+ + Polygon +
+
+ + Base +
+
+ + Arbitrum +
+
+ + Avalanche +
+
+
+ + +
+
+
Live Demo
+

See It in Action

+

+ Explore the TEC Commons Fund — a real multi-chain Safe wallet + managing community funds on Gnosis and beyond. +

+ +

0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1

+
+
-
+ + + + + + diff --git a/js/data-transform.js b/js/data-transform.js new file mode 100644 index 0000000..3543875 --- /dev/null +++ b/js/data-transform.js @@ -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, + }; +})(); diff --git a/js/router.js b/js/router.js new file mode 100644 index 0000000..f59508b --- /dev/null +++ b/js/router.js @@ -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 = ` +
+
+ + + rWallet + + + +
+
+ `; + + 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, + }; +})(); diff --git a/js/safe-api.js b/js/safe-api.js new file mode 100644 index 0000000..05d9d41 --- /dev/null +++ b/js/safe-api.js @@ -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 + */ + 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, + }; +})(); diff --git a/wallet-multichain-visualization.html b/wallet-multichain-visualization.html index 3a10ed9..42e16ff 100644 --- a/wallet-multichain-visualization.html +++ b/wallet-multichain-visualization.html @@ -3,620 +3,262 @@ - Multi-Chain Wallet Visualization - 0x2956...7D1 + Multi-Chain Flow | rWallet.online + + +
-

🌐 Multi-Chain Wallet Flow

-

0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1

+

Multi-Chain Wallet Flow

+

Enter a Safe wallet address to visualize

- -
- - - - - - -
+
- -
-
-

Total Transfers

-
641
-
-
-

Total Inflow

-
~$99K
-
-
-

Total Outflow

-
~$63K
-
-
-

Unique Addresses

-
25+
-
-
-

Active Period

-
Mar 2023 - Jan 2026
-
-
- - -
- ⚠️ - Spam filtered: This analysis excludes fake tokens, phishing NFTs, and scam airdrops detected across all chains. -
- - -
-
Gnosis
-
Ethereum
-
Avalanche
-
Optimism
-
Arbitrum
-
- - -
-

📊 Transaction Flow Diagram

-
-
- - -
- -
-

↓ Incoming Transfers 45

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ChainDateFromTokenAmount
GNO2023-03-280x01d9...17bDWXDAI+17,000.00
GNO2023-03-220x763d...87d4TEC+1.00
GNO2023-07-050x9b55...0b4dTEC+3,624.84
GNO2023-10-040x763d...87d4WXDAI+631.09
GNO2023-10-140x5138...Ad00TEC+9,710.03
GNO2023-10-180x9b55...0b4dWXDAI+2,566.40
GNO2024-05-080xf6A7...268dZRC+500.00
GNO2024-05-140xf6A7...268dZRC+500.00
ETH2024-04-090xda1A...eE61USDC+10,000.00
ETH2024-05-080xda1A...eE61USDC+10,000.00
ETH2024-05-140xA834...21F4USDC+12,500.00
ETH2024-06-120xda1A...eE61USDC+10,000.00
ETH2024-06-280xda1A...eE61USDC+6,000.00
ETH2025-11-290x1545...87d4Yield-USD+3,876.23
ETH2025-12-020x8290...dd61GRG+25,000
AVAX2025-03-170x5129...Cd17USDC+12,500.00
AVAX2025-12-18CoW Protocol SwapUSDC+2,536.87
AVAX2025-03-100xc13f...AVAX+0.42
AVAX2025-05-070xc13f...AVAX+0.62
AVAX2025-10-020xc13f...AVAX+0.65
AVAX2026-01-290x9a9E...AVAX+0.83
OP2024-12-15OP AirdropOP+Various
OP2025-01-090x46f8...eB5LARRY+1B
OP2025-05-180x8C15...BEARY+945,563
OP2025-11-020xD152...WLFI+1,000
OPVariousDeFi YieldsVarious+LP tokens
ARB2024-11-250xd2d9...6271USDC+2,600.73
ARB2024-10-310x8e1b...a174USDC+500.00
ARB2024-11-060x8e1b...a174ARB+19.13
ARB2024-11-040x8e1b...a174USDGLO+8.00
ARBVarious0xd2d9...6271ETH+~0.05
-
-
- - -
-

↑ Outgoing Transfers 38

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ChainDateToTokenAmount
GNO2023-04-260x9b55...0b4dWXDAI-2,306.00
GNO2023-04-260x763d...87d4WXDAI-1,050.00
GNO2023-04-260x1409...8fCWXDAI-910.00
GNO2023-05-110xb282...587bWXDAI-500.00
GNO2023-06-070x9b55...0b4dWXDAI-3,235.00
GNO2023-06-070x763d...87d4WXDAI-2,280.00
GNO2023-06-070x7785...3707WXDAI-1,765.00
GNO2023-06-070x9239...0A14WXDAI-1,200.00
GNO2023-09-100x9b55...0b4dWXDAI-3,309.00
GNO2023-10-040x763d...87d4TEC-1,531.29
GNO2023-10-180x9b55...0b4dTEC-5,900.00
GNO2023-10-260x9b55...0b4dWXDAI-2,500.00
GNO2023-11-010xb282...587bTEC-236.00
GNO2023-11-010x9239...0A14WXDAI-500.00
GNO2023-12-150x7785...3707TEC-5,668.58
GNO2023-12-150x7785...3707WXDAI-197.49
ETH2024-04-100xB90B...6a98USDC-10,000.00
ETH2024-05-140xB90B...6a98USDC-10,000.00
ETH2024-06-120xB90B...6a98USDC-10,000.00
ETH2025-12-010x763d...87d4USDC-4,620.00
ETH2025-12-190x0acE...6b87eUSDC-6,090.00
ETH2026-01-220xAbf5...7749USDC-5,000.00
AVAX2025-04-170x0acE...6b87eUSDC-3,570.00
AVAX2025-04-170xAbf5...7749USDC-490.00
AVAX2025-04-170x9425...a083USDC-350.00
AVAX2025-04-170x763d...87d4USDC-2,730.00
AVAX2025-05-130x763d...87d4AVAX-129.06
AVAX2025-05-130x0acE...6b87eUSDC-2,730.00
AVAX2025-12-180x9425...a083USDC-2,000.00
AVAX2025-12-180x0acE...6b87eUSDC-2,000.00
OP2025-02-060x0acE...6b87eDAI-5,320.00
OP2025-02-060xbfC1...USDC-420.00
OPVariousMultiple recipientsVarious-Distributions
ARB2025-12-180x09b0...9822USDC-676.79
ARB2026-01-220x9425...a083USDC-5,000.00
-
+
+
+

Enter a Safe wallet address above to get started

+

Or try the demo: TEC Commons Fund

diff --git a/wallet-timeline-visualization.html b/wallet-timeline-visualization.html index 7d197f8..7c76e47 100644 --- a/wallet-timeline-visualization.html +++ b/wallet-timeline-visualization.html @@ -3,8 +3,11 @@ - Wallet Timeline - 0x2956...7D1 + Balance River | rWallet.online + + +
-

📈 Wallet Balance River

-

0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1

+

Wallet Balance River

+

Enter a Safe wallet address to visualize

- -
-
-

Total Inflow

-
$0
-
-
-

Total Outflow

-
$0
-
-
-

Net Change

-
$0
-
-
-

Peak Balance

-
$0
-
-
-

Transactions

-
0
-
-
+
- -
-
- - +
+
+

Enter a Safe wallet address above to get started

+

Or try the demo: TEC Commons Fund

-
- - -
-
- -
-
- -

🖱️ Scroll up/down to zoomScroll left/right (or Shift+scroll) to panClick and drag to pan • Hover for details

- - -
-
-
- Inflows (green → blue) -
-
-
- Balance River -
-
-
- Outflows (blue → red) -
-
- - -
-
diff --git a/wallet-visualization.html b/wallet-visualization.html index a2baec5..55ffc28 100644 --- a/wallet-visualization.html +++ b/wallet-visualization.html @@ -3,9 +3,12 @@ - Wallet Flow Visualization - 0x2956...7D1 + Single-Chain Flow | rWallet.online + + +

Wallet Transaction Flow

-

gno:0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1

+

Enter a Safe wallet address to visualize

-
-
-

Total Inflow (WXDAI)

-
+20,197 DAI
-
-
-

Total Outflow (WXDAI)

-
-17,697 DAI
-
-
-

Total Inflow (TEC)

-
+14,336 TEC
-
-
-

Total Outflow (TEC)

-
-13,336 TEC
-
-
-

Unique Counterparties

-
8 addresses
-
-
-

Active Period

-
Mar 2023 - Dec 2023
-
-
+
-
- ⚠️ Note: 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. -
+ -
-
WXDAI
-
TEC
-
Inflow
-
Outflow
-
- -
- -
-
-

↓ Incoming Transfers

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
DateFromTokenAmount
2023-03-280x01d9...17bDWXDAI+17,000.00
2023-03-220x763d...87d4TEC+1.00
2023-07-050x9b55...0b4dTEC+3,624.84
2023-10-040x763d...87d4WXDAI+631.09
2023-10-140x5138...Ad00TEC+9,710.03
2023-10-180x9b55...0b4dWXDAI+2,566.40
2024-05-080xf6A7...268dZRC+500.00
2024-05-140xf6A7...268dZRC+500.00
-
- -
-

↑ Outgoing Transfers

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
DateToTokenAmount
2023-04-260x9b55...0b4dWXDAI-2,306.00
2023-04-260x763d...87d4WXDAI-1,050.00
2023-04-260x1409...8fCWXDAI-910.00
2023-05-110xb282...587bWXDAI-500.00
2023-06-070x9b55...0b4dWXDAI-3,235.00
2023-06-070x763d...87d4WXDAI-2,280.00
2023-06-070x7785...3707WXDAI-1,765.00
2023-06-070x9239...0A14WXDAI-1,200.00
2023-06-070xb282...587bWXDAI-445.00
2023-09-100x9b55...0b4dWXDAI-3,309.00
2023-10-040x763d...87d4TEC-1,531.29
2023-10-180x9b55...0b4dTEC-5,900.00
2023-10-260x9b55...0b4dWXDAI-2,500.00
2023-11-010xb282...587bTEC-236.00
2023-11-010x9239...0A14WXDAI-500.00
2023-12-150x7785...3707TEC-5,668.58
2023-12-150x7785...3707WXDAI-197.49
+
+
+

Enter a Safe wallet address above to get started

+

Or try the demo: TEC Commons Fund