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 `
+
+
+
+
+
+
+
+
+
+
+
+
+ ${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;
},
};