From fa545c2e6c8127ba21cd175d35881b232731fac5 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 18 Dec 2025 00:39:25 -0800 Subject: [PATCH 1/4] chore: chore: working react nodes builder poc --- examples/next-js/app/builder/page.tsx | 96 +++++ .../components/builder/builder-canvas.tsx | 152 ++++++++ .../components/builder/builder-toolbar.tsx | 334 ++++++++++++++++++ .../components/builder/feedback-panel.tsx | 123 +++++++ examples/next-js/components/builder/index.ts | 12 + .../components/builder/node-inspector.tsx | 292 +++++++++++++++ .../components/builder/node-palette.tsx | 140 ++++++++ .../components/builder/nodes/base-node.tsx | 210 +++++++++++ .../next-js/components/builder/nodes/index.ts | 25 ++ .../next-js/components/navigation/app-nav.tsx | 1 + examples/next-js/lib/builder/compiler.ts | 250 +++++++++++++ examples/next-js/lib/builder/index.ts | 36 ++ .../next-js/lib/builder/node-definitions.ts | 265 ++++++++++++++ examples/next-js/lib/builder/store.ts | 252 +++++++++++++ examples/next-js/lib/builder/types.ts | 247 +++++++++++++ .../lib/builder/use-builder-feedback.ts | 149 ++++++++ examples/next-js/package.json | 15 +- pnpm-lock.yaml | 211 +++++++++++ 18 files changed, 2804 insertions(+), 6 deletions(-) create mode 100644 examples/next-js/app/builder/page.tsx create mode 100644 examples/next-js/components/builder/builder-canvas.tsx create mode 100644 examples/next-js/components/builder/builder-toolbar.tsx create mode 100644 examples/next-js/components/builder/feedback-panel.tsx create mode 100644 examples/next-js/components/builder/index.ts create mode 100644 examples/next-js/components/builder/node-inspector.tsx create mode 100644 examples/next-js/components/builder/node-palette.tsx create mode 100644 examples/next-js/components/builder/nodes/base-node.tsx create mode 100644 examples/next-js/components/builder/nodes/index.ts create mode 100644 examples/next-js/lib/builder/compiler.ts create mode 100644 examples/next-js/lib/builder/index.ts create mode 100644 examples/next-js/lib/builder/node-definitions.ts create mode 100644 examples/next-js/lib/builder/store.ts create mode 100644 examples/next-js/lib/builder/types.ts create mode 100644 examples/next-js/lib/builder/use-builder-feedback.ts diff --git a/examples/next-js/app/builder/page.tsx b/examples/next-js/app/builder/page.tsx new file mode 100644 index 0000000..943fb5c --- /dev/null +++ b/examples/next-js/app/builder/page.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useMemo } from 'react'; +import { ReactFlowProvider } from '@xyflow/react'; +import { useGillTransactionSigner, useConnectorClient } from '@solana/connector'; +import { createSolanaRpc, createSolanaRpcSubscriptions, address } from '@solana/kit'; + +import { + BuilderCanvas, + BuilderToolbar, + NodePalette, + NodeInspector, + FeedbackPanel, +} from '@/components/builder'; +import { useBuilderFeedback } from '@/lib/builder'; +import type { CompileContext } from '@/lib/builder/types'; +import { ConnectButton } from '@/components/connector'; + +// ============================================================================= +// Builder Page Content +// ============================================================================= + +function BuilderContent() { + const { signer, ready } = useGillTransactionSigner(); + const client = useConnectorClient(); + + // Create compile context from wallet connection + const compileContext = useMemo(() => { + if (!ready || !signer || !client) return null; + + const rpcUrl = client.getRpcUrl(); + if (!rpcUrl) return null; + + const rpc = createSolanaRpc(rpcUrl); + const rpcSubscriptions = createSolanaRpcSubscriptions( + rpcUrl.replace('https', 'wss').replace('http', 'ws') + ); + + return { + signer, + rpc, + rpcSubscriptions, + walletAddress: address(signer.address), + }; + }, [ready, signer, client]); + + // Get feedback for the current graph + const feedback = useBuilderFeedback(compileContext); + + return ( +
+ {/* Toolbar */} + + + {/* Main content */} +
+ {/* Node palette */} + + + {/* Canvas */} +
+ {/* Connection status banner */} + {!ready && ( +
+ + Connect your wallet to simulate and execute transactions + + +
+ )} + + {/* React Flow canvas */} + + + {/* Feedback panel */} + +
+ + {/* Inspector panel */} + +
+
+ ); +} + +// ============================================================================= +// Builder Page +// ============================================================================= + +export default function BuilderPage() { + return ( + + + + ); +} diff --git a/examples/next-js/components/builder/builder-canvas.tsx b/examples/next-js/components/builder/builder-canvas.tsx new file mode 100644 index 0000000..9f12341 --- /dev/null +++ b/examples/next-js/components/builder/builder-canvas.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useCallback, useRef } from 'react'; +import { + ReactFlow, + Background, + Controls, + MiniMap, + addEdge, + type Connection, + type ReactFlowInstance, + BackgroundVariant, + ConnectionLineType, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +import { useBuilderStore } from '@/lib/builder/store'; +import { nodeTypes } from './nodes'; +import type { NodeType } from '@/lib/builder/types'; + +// ============================================================================= +// Builder Canvas Component +// ============================================================================= + +export function BuilderCanvas() { + const reactFlowWrapper = useRef(null); + const reactFlowInstance = useRef | null>(null); + + // Store state and actions + const nodes = useBuilderStore(state => state.nodes); + const edges = useBuilderStore(state => state.edges); + const addNode = useBuilderStore(state => state.addNode); + const setEdges = useBuilderStore(state => state.setEdges); + const onNodesChange = useBuilderStore(state => state.onNodesChange); + const onEdgesChange = useBuilderStore(state => state.onEdgesChange); + const selectNode = useBuilderStore(state => state.selectNode); + + // Handle new connections + const onConnect = useCallback( + (connection: Connection) => { + setEdges(addEdge(connection, edges)); + }, + [edges, setEdges] + ); + + // Handle node selection + const onNodeClick = useCallback( + (_: React.MouseEvent, node: any) => { + selectNode(node.id); + }, + [selectNode] + ); + + // Handle canvas click (deselect) + const onPaneClick = useCallback(() => { + selectNode(null); + }, [selectNode]); + + // Handle drag over (for palette drag-drop) + const onDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }, []); + + // Handle drop from palette + const onDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + + const type = event.dataTransfer.getData('application/reactflow') as NodeType; + if (!type) return; + + // Get position relative to the canvas + const reactFlowBounds = reactFlowWrapper.current?.getBoundingClientRect(); + if (!reactFlowBounds || !reactFlowInstance.current) return; + + const position = reactFlowInstance.current.screenToFlowPosition({ + x: event.clientX - reactFlowBounds.left, + y: event.clientY - reactFlowBounds.top, + }); + + addNode(type, position); + }, + [addNode] + ); + + // Store React Flow instance + const onInit = useCallback((instance: ReactFlowInstance) => { + reactFlowInstance.current = instance; + }, []); + + return ( +
+ + + + { + switch (node.type) { + case 'wallet': + return '#9333ea'; + case 'transfer-sol': + case 'transfer-token': + return '#2563eb'; + case 'create-ata': + return '#16a34a'; + case 'memo': + return '#ea580c'; + default: + return '#64748b'; + } + }} + maskColor="rgba(0, 0, 0, 0.1)" + pannable + zoomable + /> + +
+ ); +} diff --git a/examples/next-js/components/builder/builder-toolbar.tsx b/examples/next-js/components/builder/builder-toolbar.tsx new file mode 100644 index 0000000..0d4a9e1 --- /dev/null +++ b/examples/next-js/components/builder/builder-toolbar.tsx @@ -0,0 +1,334 @@ +'use client'; + +import { useCallback, useState, useMemo } from 'react'; +import { useCluster } from '@solana/connector'; +import { useBuilderStore, useExecutionState } from '@/lib/builder/store'; +import { compileGraph } from '@/lib/builder/compiler'; +import { TransactionBuilder } from '@pipeit/core'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { + Play, + FlaskConical, + Trash2, + CheckCircle, + XCircle, + Loader2, + AlertCircle, +} from 'lucide-react'; +import type { CompileContext } from '@/lib/builder/types'; + +// ============================================================================= +// Props +// ============================================================================= + +interface BuilderToolbarProps { + compileContext: CompileContext | null; + onSimulate?: () => void; +} + +// ============================================================================= +// Toolbar Component +// ============================================================================= + +export function BuilderToolbar({ compileContext, onSimulate }: BuilderToolbarProps) { + const nodes = useBuilderStore(state => state.nodes); + const edges = useBuilderStore(state => state.edges); + const config = useBuilderStore(state => state.config); + const executionState = useExecutionState(); + const setExecutionState = useBuilderStore(state => state.setExecutionState); + const reset = useBuilderStore(state => state.reset); + const { cluster } = useCluster(); + + const [isSimulating, setIsSimulating] = useState(false); + const [simulationResult, setSimulationResult] = useState<{ + success: boolean; + computeUnits?: number; + error?: string; + } | null>(null); + + const canExecute = nodes.length > 0 && compileContext !== null; + const isExecuting = executionState.status !== 'idle' && + executionState.status !== 'success' && + executionState.status !== 'error'; + + // Build explorer URL based on cluster + const getExplorerUrl = useMemo(() => { + return (signature: string) => { + const clusterId = cluster?.id || 'solana:mainnet'; + // Solscan uses different URL patterns + if (clusterId === 'solana:mainnet' || clusterId.includes('mainnet')) { + return `https://solscan.io/tx/${signature}`; + } else if (clusterId === 'solana:devnet' || clusterId.includes('devnet')) { + return `https://solscan.io/tx/${signature}?cluster=devnet`; + } else if (clusterId === 'solana:testnet' || clusterId.includes('testnet')) { + return `https://solscan.io/tx/${signature}?cluster=testnet`; + } + // Fallback to Solana Explorer for other clusters + return `https://explorer.solana.com/tx/${signature}?cluster=${clusterId.replace('solana:', '')}`; + }; + }, [cluster]); + + // Handle simulation + const handleSimulate = useCallback(async () => { + if (!compileContext) { + console.log('[Builder] No compile context - wallet not connected'); + return; + } + + console.log('[Builder] Starting simulation with', nodes.length, 'nodes'); + setIsSimulating(true); + setSimulationResult(null); + + try { + const compiled = await compileGraph(nodes, edges, compileContext); + console.log('[Builder] Compiled', compiled.instructions.length, 'instructions'); + + if (compiled.instructions.length === 0) { + setSimulationResult({ + success: false, + error: 'No instructions to simulate. Fill in the node configuration fields.', + }); + setIsSimulating(false); + return; + } + + const builder = new TransactionBuilder({ + rpc: compileContext.rpc, + priorityFee: config.priorityFee, + computeUnits: config.computeUnits === 'auto' ? undefined : config.computeUnits, + }) + .setFeePayerSigner(compileContext.signer) + .addInstructions(compiled.instructions); + + console.log('[Builder] Running simulation (will prompt wallet for signature)...'); + const result = await builder.simulate(); + console.log('[Builder] Simulation result:', result); + + setSimulationResult({ + success: result.err === null, + computeUnits: result.unitsConsumed ? Number(result.unitsConsumed) : undefined, + error: result.err ? JSON.stringify(result.err) : undefined, + }); + } catch (error) { + console.error('[Builder] Simulation error:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + // Provide more helpful error messages + let displayError = errorMessage; + if (errorMessage.includes('Failed to sign')) { + displayError = 'Simulation requires wallet approval. Please approve in your wallet, or click Execute to run directly.'; + } else if (errorMessage.includes('User rejected')) { + displayError = 'Wallet signature rejected. Simulation cancelled.'; + } + + setSimulationResult({ + success: false, + error: displayError, + }); + } finally { + setIsSimulating(false); + } + }, [nodes, edges, compileContext, config]); + + // Handle execution + const handleExecute = useCallback(async () => { + if (!compileContext) { + console.log('[Builder] No compile context - wallet not connected'); + return; + } + + console.log('[Builder] Starting execution with', nodes.length, 'nodes'); + setExecutionState({ status: 'compiling' }); + setSimulationResult(null); + + try { + const compiled = await compileGraph(nodes, edges, compileContext); + console.log('[Builder] Compiled', compiled.instructions.length, 'instructions'); + + if (compiled.instructions.length === 0) { + setExecutionState({ + status: 'error', + error: new Error('No instructions to execute. Fill in the node configuration fields.'), + }); + return; + } + + setExecutionState({ status: 'signing' }); + console.log('[Builder] Building and signing transaction...'); + + const signature = await new TransactionBuilder({ + rpc: compileContext.rpc, + priorityFee: config.priorityFee, + computeUnits: config.computeUnits === 'auto' ? 200_000 : config.computeUnits, + autoRetry: { maxAttempts: 3, backoff: 'exponential' }, + logLevel: 'verbose', + }) + .setFeePayerSigner(compileContext.signer) + .addInstructions(compiled.instructions) + .execute({ + rpcSubscriptions: compileContext.rpcSubscriptions, + commitment: 'confirmed', + skipPreflight: false, + }); + + console.log('[Builder] Transaction successful:', signature); + setExecutionState({ status: 'success', signature }); + } catch (error) { + console.error('[Builder] Execution error:', error); + + // Provide more helpful error messages + let displayError = error instanceof Error ? error : new Error('Unknown error'); + const errorMsg = displayError.message; + + if (errorMsg.includes('block') && errorMsg.includes('progressed')) { + displayError = new Error('Transaction expired. Please try again - the network was slow to confirm.'); + } else if (errorMsg.includes('User rejected')) { + displayError = new Error('Transaction rejected by wallet.'); + } else if (errorMsg.includes('insufficient funds') || errorMsg.includes('Insufficient')) { + displayError = new Error('Insufficient funds in wallet for this transaction.'); + } + + setExecutionState({ + status: 'error', + error: displayError, + }); + } + }, [nodes, edges, compileContext, config, setExecutionState]); + + // Handle clear + const handleClear = useCallback(() => { + reset(); + setSimulationResult(null); + }, [reset]); + + return ( +
+ {/* Left side - title */} +
+

+ Transaction Builder +

+ + {nodes.length} node{nodes.length !== 1 ? 's' : ''} + + {cluster && ( + + {cluster.label || cluster.id?.replace('solana:', '')} + + )} +
+ + {/* Right side - actions */} +
+ {/* Simulation result indicator */} + {simulationResult && ( +
+ {simulationResult.success ? ( + <> + + + {simulationResult.computeUnits + ? `${simulationResult.computeUnits.toLocaleString()} CU` + : 'Valid'} + + + ) : ( + <> + + {simulationResult.error?.slice(0, 50)}{(simulationResult.error?.length ?? 0) > 50 ? '...' : ''} + + )} +
+ )} + + {/* Execution state indicator */} + {executionState.status === 'success' && ( + + + {executionState.signature.slice(0, 8)}... + + )} + + {executionState.status === 'error' && ( +
+ + {executionState.error.message.slice(0, 40)}{executionState.error.message.length > 40 ? '...' : ''} +
+ )} + + {/* Clear button */} + + + {/* Simulate button */} + + + {/* Execute button */} + +
+
+ ); +} diff --git a/examples/next-js/components/builder/feedback-panel.tsx b/examples/next-js/components/builder/feedback-panel.tsx new file mode 100644 index 0000000..b5de03c --- /dev/null +++ b/examples/next-js/components/builder/feedback-panel.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import type { BuilderFeedback } from '@/lib/builder/types'; +import { AlertCircle, CheckCircle, Loader2 } from 'lucide-react'; + +// ============================================================================= +// Props +// ============================================================================= + +interface FeedbackPanelProps { + feedback: BuilderFeedback; +} + +// ============================================================================= +// Size Meter Component +// ============================================================================= + +interface SizeMeterProps { + size: number; + limit: number; + percentUsed: number; +} + +function SizeMeter({ size, limit, percentUsed }: SizeMeterProps) { + const isWarning = percentUsed > 80; + const isDanger = percentUsed > 95; + + return ( +
+ Size +
+
+
+ + {size} / {limit} + +
+ ); +} + +// ============================================================================= +// Feedback Panel Component +// ============================================================================= + +export function FeedbackPanel({ feedback }: FeedbackPanelProps) { + const { isCompiling, sizeInfo, error } = feedback; + + return ( +
+ {/* Loading state */} + {isCompiling && ( +
+ + Analyzing transaction... +
+ )} + + {/* Size info */} + {!isCompiling && sizeInfo && ( +
+ +
+ )} + + {/* Status indicator */} + {!isCompiling && sizeInfo && ( +
+ {sizeInfo.canFitMore ? ( + <> + + Ready to simulate + + ) : ( + <> + + Transaction too large + + )} +
+ )} + + {/* Error/info state */} + {!isCompiling && error && ( +
+ + {error} +
+ )} + + {/* Empty state */} + {!isCompiling && !sizeInfo && !error && ( + + Add nodes and configure them to see transaction details + + )} +
+ ); +} diff --git a/examples/next-js/components/builder/index.ts b/examples/next-js/components/builder/index.ts new file mode 100644 index 0000000..eeb017c --- /dev/null +++ b/examples/next-js/components/builder/index.ts @@ -0,0 +1,12 @@ +/** + * Visual Transaction Builder components. + * + * @packageDocumentation + */ + +export { BuilderCanvas } from './builder-canvas'; +export { BuilderToolbar } from './builder-toolbar'; +export { NodePalette } from './node-palette'; +export { NodeInspector } from './node-inspector'; +export { FeedbackPanel } from './feedback-panel'; +export { nodeTypes } from './nodes'; diff --git a/examples/next-js/components/builder/node-inspector.tsx b/examples/next-js/components/builder/node-inspector.tsx new file mode 100644 index 0000000..cd5c488 --- /dev/null +++ b/examples/next-js/components/builder/node-inspector.tsx @@ -0,0 +1,292 @@ +'use client'; + +import { useCallback } from 'react'; +import { cn } from '@/lib/utils'; +import { useBuilderStore } from '@/lib/builder/store'; +import { getNodeDefinition } from '@/lib/builder/node-definitions'; +import type { NodeType, BuilderNodeData } from '@/lib/builder/types'; +import { Trash2, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +// ============================================================================= +// Form Field Components +// ============================================================================= + +interface TextFieldProps { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + type?: 'text' | 'number'; +} + +function TextField({ label, value, onChange, placeholder, type = 'text' }: TextFieldProps) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className={cn( + 'w-full px-3 py-2 text-sm rounded-md border border-gray-300', + 'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent', + 'placeholder:text-gray-400' + )} + /> +
+ ); +} + +interface TextAreaFieldProps { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + rows?: number; +} + +function TextAreaField({ label, value, onChange, placeholder, rows = 3 }: TextAreaFieldProps) { + return ( +
+ +