636 lines
27 KiB
JavaScript
636 lines
27 KiB
JavaScript
/**
|
|
* 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,
|
|
};
|
|
})();
|