/** * Maps Drips protocol state to rFlows FlowNode[] for visualization. * Pure functions — no side effects, no network calls. */ import type { FlowNode, SourceNodeData, FunnelNodeData, OutcomeNodeData, DripsNodeConfig, SpendingAllocation } from './types'; import type { DripsAccountState, DripsStream, DripsSplit } from './drips-client'; import { dripsAmtToMonthly } from './drips-client'; // Common token decimals const TOKEN_DECIMALS: Record = { '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 6, // USDC (mainnet) '0xdac17f958d2ee523a2206206994597c13d831ec7': 6, // USDT (mainnet) '0x6b175474e89094c44da98b954eedeac495271d0f': 18, // DAI (mainnet) '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': 18, // WETH (mainnet) }; function getTokenDecimals(tokenAddress: string): number { return TOKEN_DECIMALS[tokenAddress.toLowerCase()] ?? 18; } function getTokenSymbol(tokenAddress: string): string { const symbols: Record = { '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 'USDC', '0xdac17f958d2ee523a2206206994597c13d831ec7': 'USDT', '0x6b175474e89094c44da98b954eedeac495271d0f': 'DAI', '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': 'WETH', }; return symbols[tokenAddress.toLowerCase()] ?? 'TOKEN'; } const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']; export interface MapDripsOptions { /** Origin offset for layout positioning */ originX?: number; originY?: number; /** USD price per token (for rate conversion). Default 1 (stablecoins). */ usdPrice?: number; } /** * Map DripsAccountState into FlowNode[] for rFlows canvas. * * Layout (3-tier grid): * Row 0: Source nodes (senders streaming to this address) * Row 1: Funnel node (the imported address itself) * Row 2: Outcome nodes (split receivers) */ export function mapDripsToFlowNodes(state: DripsAccountState, options: MapDripsOptions = {}): FlowNode[] { const { originX = 100, originY = 100, usdPrice = 1 } = options; const nodes: FlowNode[] = []; const now = Date.now(); const addr = state.address.toLowerCase(); const funnelId = `drips-${addr}`; // ── Row 0: Source nodes (incoming streams grouped by sender) ── const bySender = new Map(); for (const s of state.incomingStreams) { if (s.isPaused) continue; const key = s.sender.toLowerCase(); if (!bySender.has(key)) bySender.set(key, []); bySender.get(key)!.push(s); } let srcIdx = 0; for (const [sender, streams] of bySender) { const totalRate = streams.reduce((sum, s) => { const dec = getTokenDecimals(s.tokenAddress); return sum + dripsAmtToMonthly(s.amtPerSec, dec, usdPrice); }, 0); const shortAddr = `${sender.slice(0, 6)}...${sender.slice(-4)}`; const tokens = [...new Set(streams.map(s => getTokenSymbol(s.tokenAddress)))].join('/'); const sourceId = `drips-src-${sender}`; const dripsConfig: DripsNodeConfig = { chainId: state.chainId, address: sender, streamIds: streams.map(s => s.id), importedAt: now, }; nodes.push({ id: sourceId, type: 'source', position: { x: originX + srcIdx * 280, y: originY }, data: { label: `${shortAddr} (${tokens})`, flowRate: Math.round(totalRate * 100) / 100, sourceType: 'safe_wallet', targetAllocations: [{ targetId: funnelId, percentage: 100, color: '#10b981', }], dripsConfig, } as SourceNodeData, }); srcIdx++; } // ── Row 1: Funnel node (the imported address) ── const totalInflow = nodes.reduce((sum, n) => { if (n.type === 'source') return sum + (n.data as SourceNodeData).flowRate; return sum; }, 0); // Build spending allocations from splits const spendingAllocations: SpendingAllocation[] = state.splits.map((split, i) => { const receiverAddr = split.receiver.toLowerCase(); return { targetId: `drips-out-${receiverAddr}`, percentage: (split.weight / 1_000_000) * 100, color: SPENDING_COLORS[i % SPENDING_COLORS.length], }; }); // Unallocated remainder (no splits receiver) stays in funnel const totalSplitPct = spendingAllocations.reduce((s, a) => s + a.percentage, 0); const funnelConfig: DripsNodeConfig = { chainId: state.chainId, address: state.address, importedAt: now, }; nodes.push({ id: funnelId, type: 'funnel', position: { x: originX + Math.max(0, (srcIdx - 1)) * 140, y: originY + 250 }, data: { label: `${state.address.slice(0, 6)}...${state.address.slice(-4)}`, currentValue: 0, overflowThreshold: totalInflow * 3, // 3 months buffer capacity: totalInflow * 6, inflowRate: totalInflow, drainRate: totalInflow * (totalSplitPct / 100), overflowAllocations: [], spendingAllocations, dripsConfig: funnelConfig, } as FunnelNodeData, }); // ── Row 2: Outcome nodes (split receivers) ── state.splits.forEach((split, i) => { const receiverAddr = split.receiver.toLowerCase(); const outcomeId = `drips-out-${receiverAddr}`; const shortAddr = `${receiverAddr.slice(0, 6)}...${receiverAddr.slice(-4)}`; const pct = (split.weight / 1_000_000) * 100; const monthlyAmount = totalInflow * (pct / 100); const dripsConfig: DripsNodeConfig = { chainId: state.chainId, address: split.receiver, splitWeight: split.weight, splitIndex: i, importedAt: now, }; nodes.push({ id: outcomeId, type: 'outcome', position: { x: originX + i * 280, y: originY + 500 }, data: { label: `${shortAddr} (${pct.toFixed(1)}%)`, description: `Drips split receiver — ${pct.toFixed(1)}% of incoming`, fundingReceived: 0, fundingTarget: monthlyAmount * 12, // annualized target status: 'in-progress', dripsConfig, } as OutcomeNodeData, }); }); return nodes; }