diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index b279afd8..e9cd9ba3 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -1167,6 +1167,79 @@ font-size: 13px; } +/* ── Drips import modal ──────────────────────────────── */ +.flows-drips-overlay { + position: fixed; inset: 0; z-index: 100; + background: var(--rs-bg-overlay); display: flex; + align-items: center; justify-content: center; + animation: modalFadeIn 0.15s ease-out; +} +.flows-drips-modal { + background: var(--rs-bg-surface); border-radius: 16px; + width: 480px; max-width: 95vw; max-height: 85vh; + border: 1px solid var(--rs-border-strong); box-shadow: var(--rs-shadow-lg); + display: flex; flex-direction: column; + animation: modalSlideIn 0.2s ease-out; +} +.flows-drips__field { + display: flex; flex-direction: column; gap: 4px; + padding: 8px 20px; +} +.flows-drips__field label { + font-size: 11px; font-weight: 600; color: var(--rs-text-secondary); + text-transform: uppercase; letter-spacing: 0.5px; +} +.flows-drips__field input, .flows-drips__field select { + padding: 8px 10px; border-radius: 6px; border: 1px solid var(--rs-border-strong); + background: var(--rs-bg-surface-raised); color: var(--rs-text-primary); + font-size: 13px; font-family: ui-monospace, monospace; +} +.flows-drips__field input:focus, .flows-drips__field select:focus { + outline: none; border-color: var(--rs-primary); +} +.flows-drips__loading, .flows-drips__error { + padding: 12px 20px; font-size: 13px; +} +.flows-drips__loading { color: var(--rs-text-muted); } +.flows-drips__error { color: #ef4444; } +.flows-drips__preview { + padding: 8px 20px; display: flex; flex-direction: column; gap: 8px; +} +.flows-drips__preview-group { + display: flex; flex-direction: column; gap: 4px; +} +.flows-drips__preview-title { + font-size: 11px; font-weight: 600; color: var(--rs-text-secondary); + text-transform: uppercase; letter-spacing: 0.5px; +} +.flows-drips__node { + display: flex; align-items: center; justify-content: space-between; + padding: 6px 10px; border-radius: 6px; font-size: 12px; + border-left: 3px solid transparent; +} +.flows-drips__node--source { border-left-color: #10b981; background: #064e3b20; } +.flows-drips__node--funnel { border-left-color: #3b82f6; background: #1e3a5f20; } +.flows-drips__node--outcome { border-left-color: #ec4899; background: #4a194220; } +.flows-drips__node-label { + color: var(--rs-text-primary); font-family: ui-monospace, monospace; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 280px; +} +.flows-drips__node-rate { + color: var(--rs-text-muted); font-size: 11px; white-space: nowrap; margin-left: 8px; +} +.flows-toolbar-btn--drips { color: #818cf8; } +.flows-toolbar-btn--drips:hover { background: #312e8120; } +.flows-drips-sync-badge { + color: #818cf8; font-size: 11px !important; + border: 1px solid #4f46e5 !important; + animation: dripsPulse 2s ease-in-out infinite; +} +@keyframes dripsPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} +.flows-drips-badge rect { transition: opacity 0.2s; } + /* ── Faucet source node ──────────────────────────────── */ .faucet-pipe { transition: stroke 0.2s; } .faucet-valve { transition: fill 0.2s; cursor: pointer; } diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index d5e1c67f..2ee43c02 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -199,6 +199,14 @@ class FolkFlowsApp extends HTMLElement { private _pieDragStartAngle = 0; private _pieDragStartPcts: number[] = []; + // Drips import state + private dripsModalOpen = false; + private dripsChainId = 1; + private dripsAddress = ''; + private dripsPreviewNodes: FlowNode[] = []; + private dripsPreviewLoading = false; + private dripsPreviewError = ''; + // Tour engine private _tour!: TourEngine; @@ -1009,6 +1017,8 @@ class FolkFlowsApp extends HTMLElement { + + ${this.hasDripsSyncs() ? '' : ''}
@@ -1128,7 +1138,8 @@ class FolkFlowsApp extends HTMLElement { - ${this.flowManagerOpen ? this.renderFlowManagerModal() : ''}`; + ${this.flowManagerOpen ? this.renderFlowManagerModal() : ''} + ${this.dripsModalOpen ? this.renderDripsModal() : ''}`; } private renderFlowDropdownItems(): string { @@ -1629,6 +1640,8 @@ class FolkFlowsApp extends HTMLElement { else if (action === "analytics") this.toggleAnalytics(); else if (action === "quick-fund") this.quickFund(); else if (action === "share") this.shareState(); + else if (action === "import-drips") this.openDripsModal(); + else if (action === "resync-drips") this.resyncDrips(); else if (action === "tour") this.startTour(); else if (action === "zoom-in") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); } else if (action === "zoom-out") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); } @@ -1970,6 +1983,7 @@ class FolkFlowsApp extends HTMLElement { $${d.flowRate.toLocaleString()}/mo ${allocBar} + ${this.renderDripsBadge(n)} ${this.renderPortsSvg(n)} `; } @@ -2234,6 +2248,7 @@ class FolkFlowsApp extends HTMLElement { ${isOverflow ? `${overflowLabel} ${overflowLabel}` : ""} + ${this.renderDripsBadge(n)} ${this.renderPortsSvg(n)} `; } @@ -2333,6 +2348,7 @@ class FolkFlowsApp extends HTMLElement { ${d.linkedTaskIds!.length} ` : ""} + ${this.renderDripsBadge(n)} ${this.renderPortsSvg(n)} `; } @@ -5652,6 +5668,222 @@ class FolkFlowsApp extends HTMLElement { this.render(); } + // ─── Drips import modal ─────────────────────────────── + + private renderDripsModal(): string { + const previewHtml = this.dripsPreviewLoading + ? '
Loading...
' + : this.dripsPreviewError + ? `
${this.esc(this.dripsPreviewError)}
` + : this.dripsPreviewNodes.length > 0 + ? this.renderDripsPreview() + : ''; + + return ` +
+
+
+

Import from Drips

+ +
+
+
+ + +
+
+ + +
+ ${previewHtml} +
+ +
+
`; + } + + private renderDripsPreview(): string { + const sources = this.dripsPreviewNodes.filter(n => n.type === 'source'); + const funnels = this.dripsPreviewNodes.filter(n => n.type === 'funnel'); + const outcomes = this.dripsPreviewNodes.filter(n => n.type === 'outcome'); + + const renderGroup = (title: string, nodes: FlowNode[], cssClass: string) => { + if (nodes.length === 0) return ''; + return `
+
${title} (${nodes.length})
+ ${nodes.map(n => `
+ ${this.esc((n.data as any).label)} + ${n.type === 'source' ? `$${(n.data as SourceNodeData).flowRate.toLocaleString()}/mo` : ''} +
`).join('')} +
`; + }; + + return `
+ ${renderGroup('Sources', sources, 'flows-drips__node--source')} + ${renderGroup('Funnels', funnels, 'flows-drips__node--funnel')} + ${renderGroup('Outcomes', outcomes, 'flows-drips__node--outcome')} +
`; + } + + private openDripsModal() { + this.dripsModalOpen = true; + this.dripsPreviewNodes = []; + this.dripsPreviewError = ''; + this.render(); + this.attachDripsListeners(); + } + + private closeDripsModal() { + this.dripsModalOpen = false; + this.render(); + } + + private attachDripsListeners() { + const overlay = this.shadow.getElementById("drips-modal-overlay"); + if (!overlay) return; + + overlay.querySelector('[data-drips-action="close"]')?.addEventListener("click", () => this.closeDripsModal()); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) this.closeDripsModal(); + }); + + const chainSelect = this.shadow.getElementById("drips-chain-select") as HTMLSelectElement | null; + if (chainSelect) { + chainSelect.addEventListener("change", () => { + this.dripsChainId = Number(chainSelect.value); + }); + } + + const addrInput = this.shadow.getElementById("drips-address-input") as HTMLInputElement | null; + if (addrInput) { + addrInput.addEventListener("input", () => { + this.dripsAddress = addrInput.value.trim(); + }); + } + + overlay.querySelector('[data-drips-action="preview"]')?.addEventListener("click", () => this.dripsPreview()); + overlay.querySelector('[data-drips-action="import"]')?.addEventListener("click", () => this.dripsImport()); + } + + private async dripsPreview() { + if (!/^0x[0-9a-fA-F]{40}$/.test(this.dripsAddress)) { + this.dripsPreviewError = 'Invalid Ethereum address'; + this.dripsPreviewNodes = []; + this.render(); + this.attachDripsListeners(); + return; + } + + this.dripsPreviewLoading = true; + this.dripsPreviewError = ''; + this.render(); + this.attachDripsListeners(); + + try { + const res = await fetch(`/api/flows/drips/${this.dripsChainId}/${this.dripsAddress}/preview`); + if (!res.ok) throw new Error(`API error: ${res.status}`); + const data = await res.json() as { nodes: FlowNode[]; state: any }; + this.dripsPreviewNodes = data.nodes; + if (data.nodes.length === 0) this.dripsPreviewError = 'No streams or splits found for this address'; + } catch (e: any) { + this.dripsPreviewError = e.message || 'Failed to fetch'; + this.dripsPreviewNodes = []; + } + + this.dripsPreviewLoading = false; + this.render(); + this.attachDripsListeners(); + } + + private async dripsImport() { + if (this.dripsPreviewNodes.length === 0) return; + const token = getAccessToken(); + + try { + if (token && !this.isDemo) { + // Authenticated: use server import route + const res = await fetch('/api/flows/drips/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + space: this.space, + flowId: this.currentFlowId, + chainId: this.dripsChainId, + address: this.dripsAddress, + }), + }); + if (!res.ok) throw new Error(`Import failed: ${res.status}`); + } + + // Merge into local nodes (works for both demo and authenticated) + const existingIds = new Set(this.nodes.map(n => n.id)); + for (const node of this.dripsPreviewNodes) { + if (existingIds.has(node.id)) { + const idx = this.nodes.findIndex(n => n.id === node.id); + if (idx >= 0) this.nodes[idx] = node; + } else { + this.nodes.push(node); + } + } + + this.scheduleSave(); + this.closeDripsModal(); + } catch (e: any) { + this.dripsPreviewError = e.message || 'Import failed'; + this.render(); + this.attachDripsListeners(); + } + } + + private hasDripsSyncs(): boolean { + if (!this.localFirstClient || !this.currentFlowId) return false; + const flow = this.localFirstClient.getCanvasFlow(this.currentFlowId); + return !!(flow?.dripsSyncs && Object.keys(flow.dripsSyncs).length > 0); + } + + private async resyncDrips() { + if (!this.localFirstClient || !this.currentFlowId) return; + const flow = this.localFirstClient.getCanvasFlow(this.currentFlowId); + if (!flow?.dripsSyncs) return; + + for (const sync of Object.values(flow.dripsSyncs)) { + this.dripsChainId = sync.chainId; + this.dripsAddress = sync.address; + await this.dripsPreview(); + if (this.dripsPreviewNodes.length > 0) { + // Merge updated nodes + const existingIds = new Set(this.nodes.map(n => n.id)); + for (const node of this.dripsPreviewNodes) { + if (existingIds.has(node.id)) { + const idx = this.nodes.findIndex(n => n.id === node.id); + if (idx >= 0) this.nodes[idx] = node; + } else { + this.nodes.push(node); + } + } + } + } + this.dripsPreviewNodes = []; + this.scheduleSave(); + this.drawCanvasContent(); + } + + private renderDripsBadge(n: FlowNode): string { + const data = n.data as any; + if (!data.dripsConfig) return ''; + const s = this.getNodeSize(n); + return ` + + D + `; + } + private attachFlowManagerListeners() { const overlay = this.shadow.getElementById("flow-manager-overlay"); if (!overlay) return; diff --git a/modules/rflows/lib/drips-client.ts b/modules/rflows/lib/drips-client.ts new file mode 100644 index 00000000..25c5a5d2 --- /dev/null +++ b/modules/rflows/lib/drips-client.ts @@ -0,0 +1,294 @@ +/** + * Drips Protocol read-only client. + * + * Fetches stream/split state via Drips GraphQL API (primary) with + * direct eth_call fallback. 5-min cache + in-flight dedup. + * Pattern: mirrors rwallet/lib/defi-positions.ts. + */ + +import { getRpcUrl } from '../../rwallet/mod'; + +// ── Contract addresses ── + +export const DRIPS_CONTRACTS = { + 1: { + drips: '0xd0Dd053392db676D57317CD4fe96Fc2cCf42D0b4', + addressDriver: '0x1455d9bD6B98f95dd8FEB2b3D60ed825fcef0610', + nftDriver: '0xcf9c49B0962EDb01Cdaa5326299ba85D72405258', + }, + 11155111: { + drips: '0x74A32a38D945b9527524900429b083547DeB9bF4', + addressDriver: '0x70E1E1437AeFe8024B6780C94490662b45C3B567', + nftDriver: '0xdC773a04C0D6EFdb80E7dfF961B6a7B063a28B44', + }, +} as Record; + +// Drips subgraph API endpoints by chain +const DRIPS_API: Record = { + 1: 'https://drips-api.onrender.com/graphql', + 11155111: 'https://drips-api-sepolia.onrender.com/graphql', +}; + +// ── Types ── + +export interface DripsStream { + id: string; + sender: string; + receiver: string; + tokenAddress: string; + amtPerSec: string; // raw bigint string (extra 9 decimals) + startTime: number; + duration: number; // 0 = indefinite + isPaused: boolean; +} + +export interface DripsSplit { + receiver: string; + weight: number; // out of 1_000_000 +} + +export interface DripsAccountState { + address: string; + chainId: number; + incomingStreams: DripsStream[]; + outgoingStreams: DripsStream[]; + splits: DripsSplit[]; + splitsHash: string; + collectableAmounts: Record; // token → amount + fetchedAt: number; +} + +// ── Cache ── + +interface CacheEntry { + state: DripsAccountState; + ts: number; +} + +const TTL = 5 * 60 * 1000; +const cache = new Map(); +const inFlight = new Map>(); + +// ── Helpers ── + +/** Convert Drips amtPerSec (with 9 extra decimals) to monthly USD. */ +export function dripsAmtToMonthly(amtPerSec: string, tokenDecimals: number, usdPrice: number): number { + const raw = BigInt(amtPerSec); + // Remove 9 extra decimals from Drips encoding + const perSec = Number(raw) / 1e9; + // Convert to token units + const tokenPerSec = perSec / Math.pow(10, tokenDecimals); + // Monthly = per-sec × 60 × 60 × 24 × 30 + return tokenPerSec * usdPrice * 2_592_000; +} + +/** Compute Drips account ID from address (AddressDriver: address as uint256). */ +function addressToAccountId(address: string): string { + // AddressDriver account ID = (driverIndex << 224) | addressAsUint160 + // driverIndex for AddressDriver = 0, so accountId = address as uint256 + return BigInt(address).toString(); +} + +// ── GraphQL fetch ── + +const ACCOUNT_QUERY = ` +query DripsAccount($accountId: ID!) { + userById(accountId: $accountId) { + splitsEntries { + receiver { accountId, address } + weight + } + streams { + outgoing { + id + receiver { address } + config { amtPerSec, start, duration } + isPaused + tokenAddress + } + incoming { + id + sender { address } + config { amtPerSec, start, duration } + isPaused + tokenAddress + } + } + } +}`; + +async function fetchGraphQL(chainId: number, address: string): Promise { + const apiUrl = DRIPS_API[chainId]; + if (!apiUrl) return null; + + const accountId = addressToAccountId(address); + + try { + const res = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: ACCOUNT_QUERY, variables: { accountId } }), + signal: AbortSignal.timeout(15000), + }); + + if (!res.ok) { + console.warn(`[drips-client] GraphQL API error: ${res.status}`); + return null; + } + + const json = await res.json() as any; + const user = json.data?.userById; + if (!user) return emptyState(chainId, address); + + const incomingStreams: DripsStream[] = (user.streams?.incoming || []).map((s: any) => ({ + id: s.id || '', + sender: s.sender?.address || '', + receiver: address, + tokenAddress: s.tokenAddress || '', + amtPerSec: s.config?.amtPerSec || '0', + startTime: Number(s.config?.start || 0), + duration: Number(s.config?.duration || 0), + isPaused: s.isPaused ?? false, + })); + + const outgoingStreams: DripsStream[] = (user.streams?.outgoing || []).map((s: any) => ({ + id: s.id || '', + sender: address, + receiver: s.receiver?.address || '', + tokenAddress: s.tokenAddress || '', + amtPerSec: s.config?.amtPerSec || '0', + startTime: Number(s.config?.start || 0), + duration: Number(s.config?.duration || 0), + isPaused: s.isPaused ?? false, + })); + + const splits: DripsSplit[] = (user.splitsEntries || []).map((s: any) => ({ + receiver: s.receiver?.address || s.receiver?.accountId || '', + weight: Number(s.weight || 0), + })); + + return { + address, + chainId, + incomingStreams, + outgoingStreams, + splits, + splitsHash: '', // GraphQL doesn't return hash directly + collectableAmounts: {}, + fetchedAt: Date.now(), + }; + } catch (e) { + console.warn('[drips-client] GraphQL fetch failed:', e); + return null; + } +} + +// ── Fallback: direct eth_call ── + +// Drips.splitsHash(uint256 accountId) → bytes32 +const SPLITS_HASH_SELECTOR = '0xeca563dd'; +// Drips.hashSplits(SplitsReceiver[] memory receivers) — for verification only + +async function rpcCall(rpcUrl: string, to: string, data: string): Promise { + try { + const res = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', id: 1, method: 'eth_call', + params: [{ to, data }, 'latest'], + }), + signal: AbortSignal.timeout(10000), + }); + const json = await res.json() as any; + return json.result || null; + } catch { return null; } +} + +function padUint256(value: string): string { + return BigInt(value).toString(16).padStart(64, '0'); +} + +async function fetchViaRPC(chainId: number, address: string): Promise { + const rpcUrl = getRpcUrl(String(chainId)); + if (!rpcUrl) return null; + + const contracts = DRIPS_CONTRACTS[chainId]; + if (!contracts) return null; + + const accountId = addressToAccountId(address); + + // Get splitsHash + const splitsData = SPLITS_HASH_SELECTOR + padUint256(accountId); + const splitsHash = await rpcCall(rpcUrl, contracts.drips, splitsData); + + return { + address, + chainId, + incomingStreams: [], + outgoingStreams: [], + splits: [], // Can't enumerate splits from hash alone + splitsHash: splitsHash || '', + collectableAmounts: {}, + fetchedAt: Date.now(), + }; +} + +function emptyState(chainId: number, address: string): DripsAccountState { + return { + address, + chainId, + incomingStreams: [], + outgoingStreams: [], + splits: [], + splitsHash: '', + collectableAmounts: {}, + fetchedAt: Date.now(), + }; +} + +// ── Public API ── + +/** + * Fetch Drips account state with 5-min cache and in-flight dedup. + * Tries GraphQL first, falls back to direct eth_call. + */ +export async function getDripsAccountState(chainId: number, address: string): Promise { + const key = `${chainId}:${address.toLowerCase()}`; + + // Check cache + const cached = cache.get(key); + if (cached && Date.now() - cached.ts < TTL) return cached.state; + + // Deduplicate concurrent requests + const pending = inFlight.get(key); + if (pending) return pending; + + const promise = (async (): Promise => { + try { + // Try GraphQL first + const graphqlResult = await fetchGraphQL(chainId, address); + if (graphqlResult) { + cache.set(key, { state: graphqlResult, ts: Date.now() }); + return graphqlResult; + } + + // Fallback to RPC + const rpcResult = await fetchViaRPC(chainId, address); + if (rpcResult) { + cache.set(key, { state: rpcResult, ts: Date.now() }); + return rpcResult; + } + + return emptyState(chainId, address); + } catch (e) { + console.warn('[drips-client] Failed to fetch:', e); + return emptyState(chainId, address); + } finally { + inFlight.delete(key); + } + })(); + + inFlight.set(key, promise); + return promise; +} diff --git a/modules/rflows/lib/drips-mapper.ts b/modules/rflows/lib/drips-mapper.ts new file mode 100644 index 00000000..6654db8d --- /dev/null +++ b/modules/rflows/lib/drips-mapper.ts @@ -0,0 +1,180 @@ +/** + * 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; +} diff --git a/modules/rflows/lib/types.ts b/modules/rflows/lib/types.ts index 64b8f3fd..415499de 100644 --- a/modules/rflows/lib/types.ts +++ b/modules/rflows/lib/types.ts @@ -49,6 +49,7 @@ export interface FunnelNodeData { overflowAllocations: OverflowAllocation[]; spendingAllocations: SpendingAllocation[]; source?: IntegrationSource; + dripsConfig?: DripsNodeConfig; [key: string]: unknown; } @@ -144,6 +145,7 @@ export interface OutcomeNodeData { phases?: OutcomePhase[]; overflowAllocations?: OverflowAllocation[]; source?: IntegrationSource; + dripsConfig?: DripsNodeConfig; /** Array of "{boardId}:{taskId}" strings linking to rTasks items */ linkedTaskIds?: string[]; /** Default rTasks board to link tasks from */ @@ -161,6 +163,7 @@ export interface SourceNodeData { safeAddress?: string; transakOrderId?: string; effectiveDate?: string; + dripsConfig?: DripsNodeConfig; [key: string]: unknown; } @@ -172,6 +175,18 @@ export interface FlowNode { data: FunnelNodeData | OutcomeNodeData | SourceNodeData; } +// ─── Drips integration ─────────────────────────────── + +export interface DripsNodeConfig { + chainId: number; + address: string; + streamIds?: string[]; + splitWeight?: number; + splitIndex?: number; + isIntermediary?: boolean; + importedAt: number; +} + // ─── Mortgage types ────────────────────────────────── export interface MortgagePosition { diff --git a/modules/rflows/mod.ts b/modules/rflows/mod.ts index 849b7a1b..f4b48eff 100644 --- a/modules/rflows/mod.ts +++ b/modules/rflows/mod.ts @@ -14,7 +14,7 @@ import { renderLanding } from "./landing"; import { flowsApplets } from "./applets"; import { getTransakEnv, getTransakWebhookSecret } from "../../shared/transak"; import type { SyncServer } from '../../server/local-first/sync-server'; -import { flowsSchema, flowsDocId, type FlowsDoc, type SpaceFlow, type CanvasFlow } from './schemas'; +import { flowsSchema, flowsDocId, type FlowsDoc, type SpaceFlow, type CanvasFlow, type DripsSync } from './schemas'; import { demoNodes } from './lib/presets'; import { OpenfortProvider } from './lib/openfort'; import { boardDocId, createTaskItem } from '../rtasks/schemas'; @@ -23,6 +23,8 @@ import type { OutcomeNodeData, MortgagePosition, ReinvestmentPosition } from './ import { getAvailableProviders, getProvider, getDefaultProvider } from './lib/onramp-registry'; import type { OnrampProviderId } from './lib/onramp-provider'; import { PimlicoClient } from './lib/pimlico'; +import { getDripsAccountState } from './lib/drips-client'; +import { mapDripsToFlowNodes } from './lib/drips-mapper'; let _syncServer: SyncServer | null = null; let _openfort: OpenfortProvider | null = null; @@ -90,6 +92,18 @@ function ensureDoc(space: string): FlowsDoc { }); doc = _syncServer!.getDoc(docId)!; } + // Migrate v5 → v6: add dripsSyncs to canvas flows + if (doc.meta.version < 6) { + _syncServer!.changeDoc(docId, 'migrate to v6', (d) => { + if (d.canvasFlows) { + for (const flow of Object.values(d.canvasFlows)) { + if (!(flow as any).dripsSyncs) (flow as any).dripsSyncs = {} as any; + } + } + d.meta.version = 6 as any; + }); + doc = _syncServer!.getDoc(docId)!; + } return doc; } @@ -1198,6 +1212,83 @@ function seedTemplateFlows(space: string) { } } +// ── Drips Protocol routes ── + +const VALID_ETH_ADDR = /^0x[0-9a-fA-F]{40}$/; + +routes.get("/api/flows/drips/:chainId/:address", async (c) => { + const chainId = Number(c.req.param("chainId")); + const address = c.req.param("address"); + if (!VALID_ETH_ADDR.test(address)) return c.json({ error: "Invalid address" }, 400); + if (![1, 11155111].includes(chainId)) return c.json({ error: "Unsupported chain" }, 400); + + const state = await getDripsAccountState(chainId, address); + return c.json(state); +}); + +routes.get("/api/flows/drips/:chainId/:address/preview", async (c) => { + const chainId = Number(c.req.param("chainId")); + const address = c.req.param("address"); + if (!VALID_ETH_ADDR.test(address)) return c.json({ error: "Invalid address" }, 400); + if (![1, 11155111].includes(chainId)) return c.json({ error: "Unsupported chain" }, 400); + + const state = await getDripsAccountState(chainId, address); + const nodes = mapDripsToFlowNodes(state); + return c.json({ nodes, state }); +}); + +routes.post("/api/flows/drips/import", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Unauthorized" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const body = await c.req.json() as { + space: string; flowId: string; chainId: number; address: string; + }; + const { space, flowId, chainId, address } = body; + if (!space || !flowId || !chainId || !address) return c.json({ error: "Missing fields" }, 400); + if (!VALID_ETH_ADDR.test(address)) return c.json({ error: "Invalid address" }, 400); + + const state = await getDripsAccountState(chainId, address); + const newNodes = mapDripsToFlowNodes(state, { originX: 400, originY: 100 }); + + const docId = flowsDocId(space); + ensureDoc(space); + + _syncServer!.changeDoc(docId, 'import drips nodes', (d) => { + const flow = d.canvasFlows[flowId]; + if (!flow) return; + + // Deduplicate by drips- prefixed IDs + const existingIds = new Set(flow.nodes.map((n: any) => n.id)); + for (const node of newNodes) { + if (existingIds.has(node.id)) { + // Update existing node data + const idx = flow.nodes.findIndex((n: any) => n.id === node.id); + if (idx >= 0) flow.nodes[idx].data = node.data as any; + } else { + flow.nodes.push(node as any); + } + } + + // Record sync entry + if (!flow.dripsSyncs) flow.dripsSyncs = {} as any; + const syncKey = `${chainId}:${address.toLowerCase()}`; + (flow.dripsSyncs as any)[syncKey] = { + chainId, + address: address.toLowerCase(), + lastSyncedAt: Date.now(), + streamIds: state.incomingStreams.concat(state.outgoingStreams).map(s => s.id), + splitsHash: state.splitsHash, + importedNodeIds: newNodes.map(n => n.id), + } as DripsSync; + + flow.updatedAt = Date.now() as any; + }); + + return c.json({ ok: true, importedCount: newNodes.length, nodeIds: newNodes.map(n => n.id) }); +}); + export function getRecentFlowsForMI(space: string, limit = 5): { id: string; name: string; nodeCount: number; createdAt: number }[] { if (!_syncServer) return []; const docId = flowsDocId(space); diff --git a/modules/rflows/schemas.ts b/modules/rflows/schemas.ts index d235153a..47e23c9e 100644 --- a/modules/rflows/schemas.ts +++ b/modules/rflows/schemas.ts @@ -9,10 +9,11 @@ * v3: adds mortgagePositions and reinvestmentPositions * v4: adds budgetSegments, budgetAllocations, budgetTotalAmount * v5: adds linkedTaskIds and linkedBoardId to OutcomeNodeData + * v6: adds dripsSyncs to CanvasFlow for Drips protocol import tracking */ import type { DocSchema } from '../../shared/local-first/document'; -import type { FlowNode, MortgagePosition, ReinvestmentPosition } from './lib/types'; +import type { FlowNode, MortgagePosition, ReinvestmentPosition, DripsNodeConfig } from './lib/types'; // ── Document types ── @@ -24,6 +25,15 @@ export interface SpaceFlow { createdAt: number; } +export interface DripsSync { + chainId: number; + address: string; + lastSyncedAt: number; + streamIds: string[]; + splitsHash: string; + importedNodeIds: string[]; +} + export interface CanvasFlow { id: string; name: string; @@ -31,6 +41,7 @@ export interface CanvasFlow { createdAt: number; updatedAt: number; createdBy: string | null; + dripsSyncs?: Record; } export interface FlowsDoc { @@ -56,12 +67,12 @@ export interface FlowsDoc { export const flowsSchema: DocSchema = { module: 'flows', collection: 'data', - version: 5, + version: 6, init: (): FlowsDoc => ({ meta: { module: 'flows', collection: 'data', - version: 5, + version: 6, spaceSlug: '', createdAt: Date.now(), }, @@ -93,7 +104,13 @@ export const flowsSchema: DocSchema = { } } } - doc.meta.version = 5; + // v6: add dripsSyncs to canvas flows + if (doc.canvasFlows) { + for (const flow of Object.values(doc.canvasFlows) as any[]) { + if (!flow.dripsSyncs) flow.dripsSyncs = {}; + } + } + doc.meta.version = 6; return doc; }, };