/** * Data Transform Module for rWallet * Converts Safe Global API responses into formats for D3 visualizations. * TypeScript port of rwallet-online/js/data-transform.js */ // ── Interfaces ── export interface TimelineEntry { date: Date; type: "in" | "out"; amount: number; token: string; usd: number; hasUsdEstimate: boolean; chain: string; chainId: string; from?: string; fromFull?: string; to?: string; toFull?: string; } export interface SankeyNode { name: string; type: "wallet" | "source" | "target"; address: string; } export interface SankeyLink { source: number; target: number; value: number; token: string; } export interface SankeyData { nodes: SankeyNode[]; links: SankeyLink[]; } export interface ChainStats { transfers: number; inflow: string; outflow: string; addresses: string; period: string; } export interface TransferRecord { chainId: string; chainName: string; date: string; from?: string; fromShort?: string; to?: string; toShort?: string; token: string; amount: number; usd: number; } export interface MultichainData { chainStats: Record; flowData: Record; allTransfers: { incoming: TransferRecord[]; outgoing: TransferRecord[] }; } export interface FlowEntry { from: string; to: string; value: number; token: string; chain: string; } // ── Helpers ── export function shortenAddress(addr: string): string { if (!addr || addr.length < 10) return addr || "Unknown"; return addr.slice(0, 6) + "..." + addr.slice(-4); } const EXPLORER_URLS: Record = { "1": "https://etherscan.io", "10": "https://optimistic.etherscan.io", "100": "https://gnosisscan.io", "137": "https://polygonscan.com", "8453": "https://basescan.org", "42161": "https://arbiscan.io", "42220": "https://celoscan.io", "43114": "https://snowtrace.io", "56": "https://bscscan.com", "324": "https://explorer.zksync.io", }; export function explorerLink(address: string, chainId: string): string { const base = EXPLORER_URLS[chainId]; if (!base) return "#"; return `${base}/address/${address}`; } export function txExplorerLink(txHash: string, chainId: string): string { const base = EXPLORER_URLS[chainId]; if (!base) return "#"; return `${base}/tx/${txHash}`; } export function getTransferValue(transfer: any): number { 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; } export function getTokenSymbol(transfer: any): string { return transfer.tokenInfo?.symbol || transfer.token?.symbol || "ETH"; } function getTokenName(transfer: any): string { 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", ]); export function estimateUSD(value: number, symbol: string): number | null { if (STABLECOINS.has(symbol)) return value; return null; } // ── Native token symbols per chain ── const CHAIN_NATIVE_SYMBOL: Record = { "1": "ETH", "10": "ETH", "100": "xDAI", "137": "MATIC", "8453": "ETH", "42161": "ETH", "42220": "CELO", "43114": "AVAX", "56": "BNB", "324": "ETH", }; // ── Transform: Timeline Data (for Balance River) ── export function transformToTimelineData( chainDataMap: Map, safeAddress: string, chainNames: Record, ): TimelineEntry[] { const timeline: any[] = []; for (const [chainId, data] of chainDataMap) { const chainName = (chainNames[chainId] || `chain-${chainId}`).toLowerCase(); // 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, 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; const txTransfers: { to: string; value: number; symbol: string; usd: number | null }[] = []; // 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: parse from dataDecoded or direct value if (txTransfers.length === 0) { if (tx.value && tx.value !== "0") { const val = parseFloat(tx.value) / 1e18; const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH"; txTransfers.push({ to: tx.to, value: val, symbol: sym, usd: estimateUSD(val, sym) }); } if (tx.dataDecoded?.method === "transfer") { const params = tx.dataDecoded.parameters || []; const to = params.find((p: any) => p.name === "to")?.value; const rawVal = params.find((p: any) => p.name === "value")?.value || "0"; const val = parseFloat(rawVal) / 1e18; txTransfers.push({ to, value: val, symbol: "Token", usd: null }); } if (tx.dataDecoded?.method === "multiSend") { const txsParam = tx.dataDecoded.parameters?.find((p: any) => 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 = CHAIN_NATIVE_SYMBOL[chainId] || "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: any) => p.name === "to")?.value; const raw2 = inner.dataDecoded.parameters?.find((p: any) => 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, hasUsdEstimate: t.usd !== null, chain: chainName, chainId, to: shortenAddress(t.to), toFull: t.to, }); } } } } return timeline .filter((t) => t.date) .map((t) => ({ ...t, date: new Date(t.date) })) .sort((a, b) => a.date.getTime() - b.date.getTime()); } // ── Transform: Sankey Data (for single-chain flow) ── export function transformToSankeyData(chainData: any, safeAddress: string, chainId?: string): SankeyData { const nodeMap = new Map(); const nodes: SankeyNode[] = []; const links: SankeyLink[] = []; const walletLabel = "Safe Wallet"; function getNodeIndex(address: string, type: "wallet" | "source" | "target"): number { 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; if (tx.value && tx.value !== "0" && tx.to) { const val = parseFloat(tx.value) / 1e18; const sym = (chainId && CHAIN_NATIVE_SYMBOL[chainId]) || "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); } if (tx.dataDecoded?.method === "transfer") { const params = tx.dataDecoded.parameters || []; const to = params.find((p: any) => p.name === "to")?.value; const rawVal = params.find((p: any) => 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); } } if (tx.dataDecoded?.method === "multiSend") { const txsParam = tx.dataDecoded.parameters?.find((p: any) => 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 = (chainId && CHAIN_NATIVE_SYMBOL[chainId]) || "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: any) => p.name === "to")?.value; const raw2 = inner.dataDecoded.parameters?.find((p: any) => 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; const filteredLinks = links.filter((l) => l.value >= threshold); return { nodes, links: filteredLinks }; } // ── Transform: Multi-Chain Flow Data ── function formatUSDValue(value: number): string { if (value >= 1000000) return `~$${(value / 1000000).toFixed(1)}M`; if (value >= 1000) return `~$${Math.round(value / 1000)}K`; return `~$${Math.round(value)}`; } export function transformToMultichainData( chainDataMap: Map, safeAddress: string, chainNames: Record, ): MultichainData { const chainStats: Record = {}; const flowData: Record = {}; const allTransfers: { incoming: TransferRecord[]; outgoing: TransferRecord[] } = { incoming: [], outgoing: [] }; let totalTransfers = 0; let totalInflow = 0; let totalOutflow = 0; const allAddresses = new Set(); let minDate: Date | null = null; let maxDate: Date | null = null; for (const [chainId, data] of chainDataMap) { const chainName = (chainNames[chainId] || `chain-${chainId}`).toLowerCase(); let chainTransfers = 0; let chainInflow = 0; let chainOutflow = 0; const chainAddresses = new Set(); let chainMinDate: Date | null = null; let chainMaxDate: Date | null = null; const flows: FlowEntry[] = []; // 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; } 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, }); } } 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; } const outTransfers: { to: string; value: number; symbol: string }[] = []; if (tx.value && tx.value !== "0" && tx.to) { const val = parseFloat(tx.value) / 1e18; const sym = CHAIN_NATIVE_SYMBOL[chainId] || "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: any) => p.name === "to")?.value; const rawVal = params.find((p: any) => 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: any) => 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 = CHAIN_NATIVE_SYMBOL[chainId] || "ETH"; outTransfers.push({ to: inner.to, value: val, symbol: sym }); } if (inner.dataDecoded?.method === "transfer") { const to2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "to")?.value; const raw2 = inner.dataDecoded.parameters?.find((p: any) => 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, }); } } } for (const [, agg] of outflowAgg) { flows.push({ from: "Safe Wallet", to: agg.to, value: Math.round(agg.value), token: agg.token, chain: chainName }); } const fmt = (d: Date) => d.toLocaleDateString("en-US", { month: "short", year: "numeric" }); const period = (chainMinDate && chainMaxDate) ? `${fmt(chainMinDate)} - ${fmt(chainMaxDate)}` : "No data"; chainStats[chainName] = { transfers: chainTransfers, inflow: formatUSDValue(chainInflow), outflow: formatUSDValue(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 fmtAll = (d: Date) => d.toLocaleDateString("en-US", { month: "short", year: "numeric" }); chainStats["all"] = { transfers: totalTransfers, inflow: formatUSDValue(totalInflow), outflow: formatUSDValue(totalOutflow), addresses: String(allAddresses.size), period: (minDate && maxDate) ? `${fmtAll(minDate)} - ${fmtAll(maxDate)}` : "No data", }; // Aggregate "all" flows: top 15 by value const allFlows: FlowEntry[] = []; for (const flows of Object.values(flowData)) { allFlows.push(...flows); } 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).getTime() - new Date(a.date).getTime()); allTransfers.outgoing.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); return { chainStats, flowData, allTransfers }; }