Compare commits
2 Commits
a5b8ecd234
...
092f40d510
| Author | SHA1 | Date |
|---|---|---|
|
|
092f40d510 | |
|
|
7238c74d31 |
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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">×</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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue