-
Edge width = relative flow amount
+
+ setPanelsCollapsed((c) => !c)}
+ >
+
+
Legend
+ {panelsCollapsed && (
+
+ )}
+
+ {!panelsCollapsed && (
+
+
+
+
+
Source (funding origin)
+
+
+
+
Funnel (threshold pool)
+
+
+
+
Outcome (deliverable)
+
+
+
+ Edge width = relative flow • Select + Delete to remove
+
+
+
+ )}
diff --git a/components/IntegrationPanel.tsx b/components/IntegrationPanel.tsx
index 370cdb3..2e97e9d 100644
--- a/components/IntegrationPanel.tsx
+++ b/components/IntegrationPanel.tsx
@@ -88,7 +88,7 @@ export default function IntegrationPanel({
let xOffset = 0
balances.forEach((chainBalances, chainId) => {
- const nodes = safeBalancesToFunnels(chainBalances, safeAddress, chainId, {
+ const nodes = safeBalancesToFunnels(chainBalances, safeAddress, chainId, undefined, {
x: xOffset,
y: 100,
})
diff --git a/components/nodes/SourceNode.tsx b/components/nodes/SourceNode.tsx
new file mode 100644
index 0000000..98189d7
--- /dev/null
+++ b/components/nodes/SourceNode.tsx
@@ -0,0 +1,208 @@
+'use client'
+
+import { memo, useState, useCallback } from 'react'
+import { Handle, Position, useReactFlow } from '@xyflow/react'
+import type { NodeProps } from '@xyflow/react'
+import type { SourceNodeData } from '@/lib/types'
+
+const SOURCE_COLORS = ['#10b981', '#14b8a6', '#06b6d4', '#0ea5e9']
+
+function SourceNode({ data, selected, id }: NodeProps) {
+ const nodeData = data as SourceNodeData
+ const { label, flowRate, sourceType, targetAllocations = [] } = nodeData
+ const { setNodes } = useReactFlow()
+
+ const [isEditing, setIsEditing] = useState(false)
+ const [editLabel, setEditLabel] = useState(label)
+ const [editRate, setEditRate] = useState(String(flowRate))
+ const [editType, setEditType] = useState(sourceType)
+
+ const handleDoubleClick = useCallback((e: React.MouseEvent) => {
+ e.stopPropagation()
+ setEditLabel(label)
+ setEditRate(String(flowRate))
+ setEditType(sourceType)
+ setIsEditing(true)
+ }, [label, flowRate, sourceType])
+
+ const handleSave = useCallback(() => {
+ setNodes((nds) => nds.map((node) => {
+ if (node.id !== id) return node
+ return {
+ ...node,
+ data: {
+ ...(node.data as SourceNodeData),
+ label: editLabel.trim() || 'Source',
+ flowRate: Math.max(0, Number(editRate) || 0),
+ sourceType: editType,
+ },
+ }
+ }))
+ setIsEditing(false)
+ }, [id, editLabel, editRate, editType, setNodes])
+
+ const typeLabels = {
+ 'recurring': 'Recurring',
+ 'one-time': 'One-time',
+ 'treasury': 'Treasury',
+ }
+
+ const typeColors = {
+ 'recurring': 'bg-emerald-100 text-emerald-700',
+ 'one-time': 'bg-blue-100 text-blue-700',
+ 'treasury': 'bg-violet-100 text-violet-700',
+ }
+
+ return (
+ <>
+
+ {/* Header */}
+
+
+ {/* Body */}
+
+
+
+ {typeLabels[sourceType]}
+
+
+
+
+
+ ${flowRate.toLocaleString()}
+
+ /mo
+
+
+ {/* Allocation bars */}
+ {targetAllocations.length > 0 && (
+
+
Flow
+
+ {targetAllocations.map((alloc, idx) => (
+
+ ))}
+
+
+ )}
+
+ {/* Flow indicator */}
+
+
+
+ Double-click to edit
+
+
+
+ {/* Bottom handle — connects to funnel top */}
+
+
+
+ {/* Edit Modal */}
+ {isEditing && (
+
setIsEditing(false)}
+ >
+
e.stopPropagation()}
+ >
+
+
Edit Source
+
+
+
+
+
+
+ setEditLabel(e.target.value)}
+ className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm text-slate-800"
+ autoFocus
+ />
+
+
+
+
+ setEditRate(e.target.value)}
+ className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm text-slate-800 font-mono"
+ min="0"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+ >
+ )
+}
+
+export default memo(SourceNode)
diff --git a/lib/api/safe-client.ts b/lib/api/safe-client.ts
index b541874..8ef6e46 100644
--- a/lib/api/safe-client.ts
+++ b/lib/api/safe-client.ts
@@ -13,6 +13,22 @@ export interface ChainConfig {
}
export const SUPPORTED_CHAINS: Record
= {
+ 1: {
+ name: 'Ethereum',
+ slug: 'mainnet',
+ txService: 'https://safe-transaction-mainnet.safe.global',
+ explorer: 'https://etherscan.io',
+ color: '#627eea',
+ symbol: 'ETH',
+ },
+ 10: {
+ name: 'Optimism',
+ slug: 'optimism',
+ txService: 'https://safe-transaction-optimism.safe.global',
+ explorer: 'https://optimistic.etherscan.io',
+ color: '#ff0420',
+ symbol: 'ETH',
+ },
100: {
name: 'Gnosis',
slug: 'gnosis-chain',
@@ -21,12 +37,28 @@ export const SUPPORTED_CHAINS: Record = {
color: '#04795b',
symbol: 'xDAI',
},
- 10: {
- name: 'Optimism',
- slug: 'optimism',
- txService: 'https://safe-transaction-optimism.safe.global',
- explorer: 'https://optimistic.etherscan.io',
- color: '#ff0420',
+ 137: {
+ name: 'Polygon',
+ slug: 'polygon',
+ txService: 'https://safe-transaction-polygon.safe.global',
+ explorer: 'https://polygonscan.com',
+ color: '#8247e5',
+ symbol: 'MATIC',
+ },
+ 8453: {
+ name: 'Base',
+ slug: 'base',
+ txService: 'https://safe-transaction-base.safe.global',
+ explorer: 'https://basescan.org',
+ color: '#0052ff',
+ symbol: 'ETH',
+ },
+ 42161: {
+ name: 'Arbitrum One',
+ slug: 'arbitrum',
+ txService: 'https://safe-transaction-arbitrum.safe.global',
+ explorer: 'https://arbiscan.io',
+ color: '#28a0f0',
symbol: 'ETH',
},
}
@@ -148,3 +180,99 @@ export async function detectSafeChains(
return results
}
+
+// ─── Transfer History ──────────────────────────────────────
+
+export interface SafeTransfer {
+ type: 'ETHER_TRANSFER' | 'ERC20_TRANSFER'
+ executionDate: string
+ transactionHash: string
+ to: string
+ from: string
+ value: string
+ tokenAddress: string | null
+ tokenInfo?: {
+ name: string
+ symbol: string
+ decimals: number
+ }
+}
+
+export interface TransferSummary {
+ chainId: number
+ totalInflow30d: number
+ totalOutflow30d: number
+ inflowRate: number
+ outflowRate: number
+ incomingTransfers: SafeTransfer[]
+ outgoingTransfers: SafeTransfer[]
+}
+
+export async function getIncomingTransfers(
+ address: string,
+ chainId: number,
+ limit = 100
+): Promise {
+ const data = await fetchJSON<{ results: SafeTransfer[] }>(
+ apiUrl(chainId, `/safes/${address}/incoming-transfers/?limit=${limit}&executed=true`)
+ )
+ return data?.results || []
+}
+
+export async function getOutgoingTransfers(
+ address: string,
+ chainId: number,
+ limit = 100
+): Promise {
+ const data = await fetchJSON<{ results: Array> }>(
+ apiUrl(chainId, `/safes/${address}/multisig-transactions/?limit=${limit}&executed=true`)
+ )
+ if (!data?.results) return []
+
+ return data.results
+ .filter(tx => tx.value && parseInt(tx.value as string, 10) > 0)
+ .map(tx => ({
+ type: (tx.dataDecoded ? 'ERC20_TRANSFER' : 'ETHER_TRANSFER') as SafeTransfer['type'],
+ executionDate: (tx.executionDate as string) || '',
+ transactionHash: (tx.transactionHash as string) || '',
+ to: (tx.to as string) || '',
+ from: address,
+ value: (tx.value as string) || '0',
+ tokenAddress: null,
+ tokenInfo: undefined,
+ }))
+}
+
+export async function computeTransferSummary(
+ address: string,
+ chainId: number
+): Promise {
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
+
+ const [incoming, outgoing] = await Promise.all([
+ getIncomingTransfers(address, chainId),
+ getOutgoingTransfers(address, chainId),
+ ])
+
+ const recentIncoming = incoming.filter(t => new Date(t.executionDate) >= thirtyDaysAgo)
+ const recentOutgoing = outgoing.filter(t => new Date(t.executionDate) >= thirtyDaysAgo)
+
+ const sumTransfers = (transfers: SafeTransfer[]) =>
+ transfers.reduce((sum, t) => {
+ const decimals = t.tokenInfo?.decimals ?? 18
+ return sum + parseFloat(t.value) / Math.pow(10, decimals)
+ }, 0)
+
+ const totalIn = sumTransfers(recentIncoming)
+ const totalOut = sumTransfers(recentOutgoing)
+
+ return {
+ chainId,
+ totalInflow30d: totalIn,
+ totalOutflow30d: totalOut,
+ inflowRate: totalIn,
+ outflowRate: totalOut,
+ incomingTransfers: recentIncoming,
+ outgoingTransfers: recentOutgoing,
+ }
+}
diff --git a/lib/integrations.ts b/lib/integrations.ts
index 4609e25..b38aadf 100644
--- a/lib/integrations.ts
+++ b/lib/integrations.ts
@@ -3,7 +3,7 @@
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData, IntegrationSource } from './types'
-import type { SafeBalance } from './api/safe-client'
+import type { SafeBalance, TransferSummary } from './api/safe-client'
// ─── Safe Balances → Funnel Nodes ────────────────────────────
@@ -11,6 +11,7 @@ export function safeBalancesToFunnels(
balances: SafeBalance[],
safeAddress: string,
chainId: number,
+ transferSummary?: TransferSummary,
startPosition = { x: 0, y: 100 }
): FlowNode[] {
// Filter to non-zero balances with meaningful fiat value (> $1)
@@ -31,13 +32,26 @@ export function safeBalancesToFunnels(
lastFetchedAt: Date.now(),
}
+ // Compute per-token inflow rate from transfer summary
+ let inflowRate = 0
+ if (transferSummary) {
+ const tokenTransfers = transferSummary.incomingTransfers.filter(t => {
+ if (b.tokenAddress === null) return t.tokenAddress === null
+ return t.tokenAddress?.toLowerCase() === b.tokenAddress?.toLowerCase()
+ })
+ inflowRate = tokenTransfers.reduce((sum, t) => {
+ const decimals = t.tokenInfo?.decimals ?? (b.token?.decimals ?? 18)
+ return sum + parseFloat(t.value) / Math.pow(10, decimals)
+ }, 0)
+ }
+
const data: FunnelNodeData = {
label: `${b.symbol} Treasury`,
currentValue: fiatValue,
minThreshold: Math.round(fiatValue * 0.2),
maxThreshold: Math.round(fiatValue * 0.8),
maxCapacity: Math.round(fiatValue * 1.5),
- inflowRate: 0,
+ inflowRate,
overflowAllocations: [],
spendingAllocations: [],
source,
diff --git a/lib/presets.ts b/lib/presets.ts
index 2e487df..2a21164 100644
--- a/lib/presets.ts
+++ b/lib/presets.ts
@@ -1,12 +1,27 @@
-import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types'
+import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from './types'
// Colors for allocations
export const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
export const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
+export const SOURCE_COLORS = ['#10b981', '#14b8a6', '#06b6d4', '#0ea5e9']
-// Demo preset: Treasury → 3 sub-funnels → 7 outcomes
+// Demo preset: Source → Treasury → 3 sub-funnels → 7 outcomes
export const demoNodes: FlowNode[] = [
- // Main Treasury Funnel (top center)
+ // Revenue source (top)
+ {
+ id: 'revenue',
+ type: 'source',
+ position: { x: 660, y: -200 },
+ data: {
+ label: 'Revenue Stream',
+ flowRate: 5000,
+ sourceType: 'recurring',
+ targetAllocations: [
+ { targetId: 'treasury', percentage: 100, color: '#10b981' },
+ ],
+ } as SourceNodeData,
+ },
+ // Main Treasury Funnel
{
id: 'treasury',
type: 'funnel',
@@ -18,6 +33,8 @@ export const demoNodes: FlowNode[] = [
maxThreshold: 70000,
maxCapacity: 100000,
inflowRate: 1000,
+ sufficientThreshold: 60000,
+ dynamicOverflow: true,
overflowAllocations: [
{ targetId: 'public-goods', percentage: 40, color: OVERFLOW_COLORS[0] },
{ targetId: 'research', percentage: 35, color: OVERFLOW_COLORS[1] },
@@ -40,6 +57,7 @@ export const demoNodes: FlowNode[] = [
maxThreshold: 50000,
maxCapacity: 70000,
inflowRate: 400,
+ sufficientThreshold: 42000,
overflowAllocations: [],
spendingAllocations: [
{ targetId: 'pg-infra', percentage: 50, color: SPENDING_COLORS[0] },
@@ -59,6 +77,7 @@ export const demoNodes: FlowNode[] = [
maxThreshold: 45000,
maxCapacity: 60000,
inflowRate: 350,
+ sufficientThreshold: 38000,
overflowAllocations: [],
spendingAllocations: [
{ targetId: 'research-grants', percentage: 70, color: SPENDING_COLORS[0] },
@@ -77,6 +96,7 @@ export const demoNodes: FlowNode[] = [
maxThreshold: 60000,
maxCapacity: 80000,
inflowRate: 250,
+ sufficientThreshold: 50000,
overflowAllocations: [],
spendingAllocations: [
{ targetId: 'emergency-response', percentage: 100, color: SPENDING_COLORS[0] },
diff --git a/lib/simulation.ts b/lib/simulation.ts
index 499ee06..0b63dce 100644
--- a/lib/simulation.ts
+++ b/lib/simulation.ts
@@ -3,9 +3,12 @@
*
* Replaces the random-noise simulation with actual flow logic:
* inflow → overflow distribution → spending drain → outcome accumulation
+ *
+ * Sufficiency layer: funnels can declare a sufficientThreshold and dynamicOverflow.
+ * When dynamicOverflow is true, surplus routes to the most underfunded targets by need-weight.
*/
-import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types'
+import type { FlowNode, FunnelNodeData, OutcomeNodeData, SufficiencyState } from './types'
export interface SimulationConfig {
tickDivisor: number // inflowRate divided by this per tick
@@ -21,6 +24,85 @@ export const DEFAULT_CONFIG: SimulationConfig = {
spendingRateCritical: 0.1,
}
+// ─── Sufficiency helpers ────────────────────────────────────
+
+export function computeSufficiencyState(data: FunnelNodeData): SufficiencyState {
+ const threshold = data.sufficientThreshold ?? data.maxThreshold
+ if (data.currentValue >= data.maxCapacity) return 'abundant'
+ if (data.currentValue >= threshold) return 'sufficient'
+ return 'seeking'
+}
+
+/**
+ * Compute need-weights for a set of overflow target IDs.
+ * For funnels: need = max(0, 1 - currentValue / sufficientThreshold)
+ * For outcomes: need = max(0, 1 - fundingReceived / fundingTarget)
+ * Returns a Map of targetId → percentage (normalized to 100).
+ */
+export function computeNeedWeights(
+ targetIds: string[],
+ allNodes: FlowNode[],
+): Map {
+ const nodeMap = new Map(allNodes.map(n => [n.id, n]))
+ const needs = new Map()
+
+ for (const tid of targetIds) {
+ const node = nodeMap.get(tid)
+ if (!node) { needs.set(tid, 0); continue }
+
+ if (node.type === 'funnel') {
+ const d = node.data as FunnelNodeData
+ const threshold = d.sufficientThreshold ?? d.maxThreshold
+ const need = Math.max(0, 1 - d.currentValue / (threshold || 1))
+ needs.set(tid, need)
+ } else if (node.type === 'outcome') {
+ const d = node.data as OutcomeNodeData
+ const need = Math.max(0, 1 - d.fundingReceived / Math.max(d.fundingTarget, 1))
+ needs.set(tid, need)
+ } else {
+ needs.set(tid, 0)
+ }
+ }
+
+ // Normalize to percentages summing to 100
+ const totalNeed = Array.from(needs.values()).reduce((s, n) => s + n, 0)
+ const weights = new Map()
+ if (totalNeed === 0) {
+ // Equal distribution when all targets are satisfied
+ const equal = targetIds.length > 0 ? 100 / targetIds.length : 0
+ targetIds.forEach(id => weights.set(id, equal))
+ } else {
+ needs.forEach((need, id) => {
+ weights.set(id, (need / totalNeed) * 100)
+ })
+ }
+ return weights
+}
+
+/**
+ * Compute system-wide sufficiency score (0-1).
+ * Averages fill ratios of all funnels and progress ratios of all outcomes.
+ */
+export function computeSystemSufficiency(nodes: FlowNode[]): number {
+ let sum = 0
+ let count = 0
+
+ for (const node of nodes) {
+ if (node.type === 'funnel') {
+ const d = node.data as FunnelNodeData
+ const threshold = d.sufficientThreshold ?? d.maxThreshold
+ sum += Math.min(1, d.currentValue / (threshold || 1))
+ count++
+ } else if (node.type === 'outcome') {
+ const d = node.data as OutcomeNodeData
+ sum += Math.min(1, d.fundingReceived / Math.max(d.fundingTarget, 1))
+ count++
+ }
+ }
+
+ return count > 0 ? sum / count : 0
+}
+
export function simulateTick(
nodes: FlowNode[],
config: SimulationConfig = DEFAULT_CONFIG,
@@ -55,12 +137,28 @@ export function simulateTick(
// 4. Distribute overflow when above maxThreshold
if (value > data.maxThreshold && data.overflowAllocations.length > 0) {
const excess = value - data.maxThreshold
- for (const alloc of data.overflowAllocations) {
- const share = excess * (alloc.percentage / 100)
- overflowIncoming.set(
- alloc.targetId,
- (overflowIncoming.get(alloc.targetId) ?? 0) + share,
- )
+
+ if (data.dynamicOverflow) {
+ // Dynamic overflow: route by need-weight instead of fixed percentages
+ const targetIds = data.overflowAllocations.map(a => a.targetId)
+ const needWeights = computeNeedWeights(targetIds, nodes)
+ for (const alloc of data.overflowAllocations) {
+ const weight = needWeights.get(alloc.targetId) ?? 0
+ const share = excess * (weight / 100)
+ overflowIncoming.set(
+ alloc.targetId,
+ (overflowIncoming.get(alloc.targetId) ?? 0) + share,
+ )
+ }
+ } else {
+ // Fixed-percentage overflow (existing behavior)
+ for (const alloc of data.overflowAllocations) {
+ const share = excess * (alloc.percentage / 100)
+ overflowIncoming.set(
+ alloc.targetId,
+ (overflowIncoming.get(alloc.targetId) ?? 0) + share,
+ )
+ }
}
value = data.maxThreshold
}
diff --git a/lib/types.ts b/lib/types.ts
index 5d8da53..139e0e5 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -83,6 +83,22 @@ export interface FundingSource {
lastUsedAt?: number;
}
+// ─── Source Node Types ───────────────────────────────────────
+
+export interface SourceAllocation {
+ targetId: string
+ percentage: number // 0-100
+ color: string
+}
+
+export interface SourceNodeData {
+ label: string
+ flowRate: number // tokens per month flowing out
+ sourceType: 'recurring' | 'one-time' | 'treasury'
+ targetAllocations: SourceAllocation[]
+ [key: string]: unknown
+}
+
// ─── Core Flow Types ─────────────────────────────────────────
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
@@ -99,6 +115,8 @@ export interface SpendingAllocation {
color: string
}
+export type SufficiencyState = 'seeking' | 'sufficient' | 'abundant'
+
export interface FunnelNodeData {
label: string
currentValue: number
@@ -106,6 +124,9 @@ export interface FunnelNodeData {
maxThreshold: number
maxCapacity: number
inflowRate: number
+ // Sufficiency layer
+ sufficientThreshold?: number // level at which funnel has "enough" (defaults to maxThreshold)
+ dynamicOverflow?: boolean // when true, overflow routes by need instead of fixed %
// Overflow goes SIDEWAYS to other funnels
overflowAllocations: OverflowAllocation[]
// Spending goes DOWN to outcomes/outputs
@@ -129,7 +150,7 @@ export interface OutcomeNodeData {
[key: string]: unknown
}
-export type FlowNode = Node
+export type FlowNode = Node
export interface AllocationEdgeData {
allocation: number // percentage 0-100