rspace-online/modules/rflows/lib/drips-mapper.ts

181 lines
5.6 KiB
TypeScript

/**
* 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<string, number> = {
'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<string, string> = {
'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<string, DripsStream[]>();
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;
}