rspace-online/modules/rwallet/lib/data-transform.ts

590 lines
19 KiB
TypeScript

/**
* 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<string, ChainStats>;
flowData: Record<string, FlowEntry[]>;
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<string, string> = {
"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<string, string> = {
"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<string, any>,
safeAddress: string,
chainNames: Record<string, string>,
): 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<string, number>();
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<string, { from: string; value: number; symbol: string }>();
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<string, { to: string; value: number; symbol: string }>();
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<string, any>,
safeAddress: string,
chainNames: Record<string, string>,
): MultichainData {
const chainStats: Record<string, ChainStats> = {};
const flowData: Record<string, FlowEntry[]> = {};
const allTransfers: { incoming: TransferRecord[]; outgoing: TransferRecord[] } = { incoming: [], outgoing: [] };
let totalTransfers = 0;
let totalInflow = 0;
let totalOutflow = 0;
const allAddresses = new Set<string>();
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<string>();
let chainMinDate: Date | null = null;
let chainMaxDate: Date | null = null;
const flows: FlowEntry[] = [];
// Incoming
const inflowAgg = new Map<string, { from: string; value: number; token: string }>();
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<string, { to: string; value: number; token: string }>();
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 };
}