Compare commits

...

2 Commits

Author SHA1 Message Date
Jeff Emmett 092f40d510 Merge branch 'dev'
CI/CD / deploy (push) Has been cancelled Details
2026-04-15 15:42:19 -04:00
Jeff Emmett 7238c74d31 feat(rflows): add Drips Protocol read-only sync — import on-chain streams/splits as flow nodes
Phase 1 integration: fetch Drips account state via GraphQL API (eth_call fallback),
map streams → Source nodes and splits → Outcome nodes with auto-layout, import into
canvas flows with dedup and resync tracking. Schema v5→v6 adds dripsSyncs to CanvasFlow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 15:41:54 -04:00
7 changed files with 908 additions and 6 deletions

View File

@ -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; }

View File

@ -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 {
<button class="flows-toolbar-btn flows-toolbar-btn--source" data-canvas-action="add-source">+ Source</button>
<button class="flows-toolbar-btn flows-toolbar-btn--funnel" data-canvas-action="add-funnel">+ Funnel</button>
<button class="flows-toolbar-btn flows-toolbar-btn--outcome" data-canvas-action="add-outcome">+ Outcome</button>
<button class="flows-toolbar-btn flows-toolbar-btn--drips" data-canvas-action="import-drips">Import Drips</button>
${this.hasDripsSyncs() ? '<button class="flows-toolbar-btn flows-drips-sync-badge" data-canvas-action="resync-drips">Resync</button>' : ''}
<div class="flows-toolbar-sep"></div>
<button class="flows-toolbar-btn" data-canvas-action="sim" id="sim-btn">${this.isSimulating ? "⏸ Pause" : "▶ Play"}</button>
<button class="flows-toolbar-btn ${this.analyticsOpen ? "flows-toolbar-btn--active" : ""}" data-canvas-action="analytics">📊 Analytics</button>
@ -1128,7 +1138,8 @@ class FolkFlowsApp extends HTMLElement {
</div>
<div class="flows-node-tooltip" id="node-tooltip" style="display:none"></div>
</div>
${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 {
<!-- Amount label -->
<text x="${valveCx}" y="${h - 18}" text-anchor="middle" fill="var(--rs-text-primary)" font-size="13" font-weight="700" font-family="ui-monospace,monospace" pointer-events="none">$${d.flowRate.toLocaleString()}/mo</text>
${allocBar}
${this.renderDripsBadge(n)}
${this.renderPortsSvg(n)}
</g>`;
}
@ -2234,6 +2248,7 @@ class FolkFlowsApp extends HTMLElement {
<!-- Overflow labels at pipe positions -->
${isOverflow ? `<text x="${-pipeW - 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="end" fill="#6ee7b7" font-size="11" font-weight="500" opacity="0.8" pointer-events="none">${overflowLabel}</text>
<text x="${w + pipeW + 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="start" fill="#6ee7b7" font-size="11" font-weight="500" opacity="0.8" pointer-events="none">${overflowLabel}</text>` : ""}
${this.renderDripsBadge(n)}
${this.renderPortsSvg(n)}
</g>`;
}
@ -2333,6 +2348,7 @@ class FolkFlowsApp extends HTMLElement {
<circle cx="0" cy="0" r="10" fill="var(--rflows-status-inprogress)" opacity="0.9"/>
<text x="0" y="4" text-anchor="middle" fill="white" font-size="9" font-weight="700">${d.linkedTaskIds!.length}</text>
</g>` : ""}
${this.renderDripsBadge(n)}
${this.renderPortsSvg(n)}
</g>`;
}
@ -5652,6 +5668,222 @@ class FolkFlowsApp extends HTMLElement {
this.render();
}
// ─── Drips import modal ───────────────────────────────
private renderDripsModal(): string {
const previewHtml = this.dripsPreviewLoading
? '<div class="flows-drips__loading">Loading...</div>'
: this.dripsPreviewError
? `<div class="flows-drips__error">${this.esc(this.dripsPreviewError)}</div>`
: this.dripsPreviewNodes.length > 0
? this.renderDripsPreview()
: '';
return `
<div class="flows-drips-overlay" id="drips-modal-overlay">
<div class="flows-drips-modal">
<div class="flows-mgmt__header">
<h2>Import from Drips</h2>
<button class="flows-mgmt__close" data-drips-action="close">&times;</button>
</div>
<div class="flows-mgmt__body">
<div class="flows-drips__field">
<label>Chain</label>
<select id="drips-chain-select">
<option value="1" ${this.dripsChainId === 1 ? 'selected' : ''}>Ethereum Mainnet</option>
<option value="11155111" ${this.dripsChainId === 11155111 ? 'selected' : ''}>Sepolia Testnet</option>
</select>
</div>
<div class="flows-drips__field">
<label>Address</label>
<input type="text" id="drips-address-input" placeholder="0x..." value="${this.esc(this.dripsAddress)}" />
</div>
${previewHtml}
</div>
<div class="flows-mgmt__footer">
<button data-drips-action="preview" ${this.dripsPreviewLoading ? 'disabled' : ''}>Preview</button>
<button class="primary" data-drips-action="import" ${this.dripsPreviewNodes.length === 0 ? 'disabled' : ''}>Import ${this.dripsPreviewNodes.length} Nodes</button>
</div>
</div>
</div>`;
}
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 `<div class="flows-drips__preview-group">
<div class="flows-drips__preview-title">${title} (${nodes.length})</div>
${nodes.map(n => `<div class="flows-drips__node ${cssClass}">
<span class="flows-drips__node-label">${this.esc((n.data as any).label)}</span>
${n.type === 'source' ? `<span class="flows-drips__node-rate">$${(n.data as SourceNodeData).flowRate.toLocaleString()}/mo</span>` : ''}
</div>`).join('')}
</div>`;
};
return `<div class="flows-drips__preview">
${renderGroup('Sources', sources, 'flows-drips__node--source')}
${renderGroup('Funnels', funnels, 'flows-drips__node--funnel')}
${renderGroup('Outcomes', outcomes, 'flows-drips__node--outcome')}
</div>`;
}
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 `<g class="flows-drips-badge" transform="translate(${s.w - 20}, -6)">
<rect x="0" y="0" width="24" height="16" rx="8" fill="#4f46e5" opacity="0.9"/>
<text x="12" y="12" text-anchor="middle" fill="white" font-size="10" font-weight="600" pointer-events="none">D</text>
</g>`;
}
private attachFlowManagerListeners() {
const overlay = this.shadow.getElementById("flow-manager-overlay");
if (!overlay) return;

View File

@ -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<number, { drips: string; addressDriver: string; nftDriver: string }>;
// Drips subgraph API endpoints by chain
const DRIPS_API: Record<number, string> = {
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<string, string>; // token → amount
fetchedAt: number;
}
// ── Cache ──
interface CacheEntry {
state: DripsAccountState;
ts: number;
}
const TTL = 5 * 60 * 1000;
const cache = new Map<string, CacheEntry>();
const inFlight = new Map<string, Promise<DripsAccountState>>();
// ── 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<DripsAccountState | null> {
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<string | null> {
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<DripsAccountState | null> {
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<DripsAccountState> {
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<DripsAccountState> => {
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;
}

View File

@ -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<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;
}

View File

@ -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 {

View File

@ -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<FlowsDoc>(docId)!;
}
// Migrate v5 → v6: add dripsSyncs to canvas flows
if (doc.meta.version < 6) {
_syncServer!.changeDoc<FlowsDoc>(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<FlowsDoc>(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<FlowsDoc>(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);

View File

@ -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<string, DripsSync>;
}
export interface FlowsDoc {
@ -56,12 +67,12 @@ export interface FlowsDoc {
export const flowsSchema: DocSchema<FlowsDoc> = {
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<FlowsDoc> = {
}
}
}
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;
},
};