diff --git a/.gitignore b/.gitignore index 5e64588..1ea373d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ coverage/ scripts/.colosseum-state.json scripts/.sipher-agent-state.json data/ +!packages/**/src/data/ diff --git a/app/designs/01-stream-view.html b/app/designs/01-stream-view.html new file mode 100644 index 0000000..277ea38 --- /dev/null +++ b/app/designs/01-stream-view.html @@ -0,0 +1,179 @@ + + + + + + + Guardian Command | Sipher + + + + + + + + + +
+
+
+ + Sipher +
+ +
+
+
+
+
+
+ Sipher +
+ 2m ago +
+

Deposited 2 SOL into vault.

+
+ TX: + 4Hc3...5s5c +
+
+ +
+
+
+
+
+
+ Herald +
+ 5m ago +
+

Posted: "Your wallet is a diary. SIP makes it private."

+
12 likes · 3 RTs
+
+ +
+
+
+
+
+
+ Sentinel +
+ 12m ago +
+

⚠ Unclaimed stealth payment: 1.5 SOL

+
+ Ephemeral key: + 0x04def...789 +
+
+ +
+
+
+
+
+
+ Sipher +
+ 15m ago +
+
+

Privacy score: 87/100

+ 12% traceable +
+
+
+
+
+
+
+
+
+ Courier +
+ 20m ago +
+

Executed scheduled send: 0.5 SOL stealth

+
+ TX: + m5oJ...qVwv +
+
+
+
+
+
+ Herald +
+ 30m ago +
+

Replied to @privacy_maxi on X

+
+
+

"Stealth addresses solve this completely. By generating ephemeral keys per transaction, the link between sender and receiver is severed on-chain."

+
+
+
+
+
+
+
+
+ + Talk to SIPHER... +
+
⌘K
+
+
+ +
+
+ + diff --git a/app/designs/02-vault-view.html b/app/designs/02-vault-view.html new file mode 100644 index 0000000..41aaa1c --- /dev/null +++ b/app/designs/02-vault-view.html @@ -0,0 +1,176 @@ + + + + + + + Guardian Command - Vault + + + + + + + + + +
+ +
+
+ + Sipher +
+ +
+ + +
+ + +
+

Vault Balance

+
+ 12.45 SOL + ≈ $2,614.50 +
+
+ + +
+
+ + +
+

Pending Operations

+
+
+
+ Drip: 0.1 SOL/day → stealth +
+ Next: 6h +
+
+
+
+ Recurring: 1 SOL weekly → 7xKz... +
+ Next: 3d +
+
+ + +
+

Recent Activity

+
+
+
+ + Deposit + 2.0 SOL +
+
+ 2h ago + Confirmed +
+
+
+
+ + Withdraw + 0.5 SOL +
+
+ 1d ago + Stealth +
+
+
+
+ + Deposit + 10.0 SOL +
+
+ 3d ago + Confirmed +
+
+
+
+ + Refund + 1.0 SOL +
+
+ 5d ago + Auto-refund +
+
+
+
+ + +
+

Fees collected: 0.062 SOL (10 bps)

+
+
+ + +
+
+
+
+ + Talk to SIPHER... +
+
⌘K
+
+
+ +
+
+ + diff --git a/app/designs/03-herald-view.html b/app/designs/03-herald-view.html new file mode 100644 index 0000000..cf84ecc --- /dev/null +++ b/app/designs/03-herald-view.html @@ -0,0 +1,178 @@ + + + + + + + + + HERALD | Guardian Command + + + + + + + + + +
+ +
+
Sipher
+ +
+ + +
+
+ X API Budget +
$47.20 / $150
+
+
+
+ + +
+ + + +
+ + +
+ + +
+
+ + +
+
+
+
Posted30m ago
+
+

"Your wallet is a diary. SIP makes it private."

+
+
+ 12 3 2 +
+ +
+
+
+
+ + +
+
+
+
Replied to@privacy_maxi45m ago
+
+

"Stealth addresses solve this completely..."

+
+
+
+
+ + +
+
+
+
Liked1h ago
+

Liked @anon_dev's post about zero-knowledge privacy

+
+
+ + +
+
+
+
DM Handled2h ago
+
+ @dev_0x: "how do I integrate?" +
Sent docs link
+
+
+
+
+ + +
+

Pending Posts (2)

+
+
+

"How Pedersen commitments hide amounts — a thread"

+
Tomorrow 9:00 AM UTC
+
+ + + +
+
+
+

"SIP vault now live on mainnet. Deposit, go private."

+
Today 6:00 PM UTC
+
+ + + +
+
+
+
+ + +
+

Recent DMs

+
+
+
@dev_0xResolved
+

"how do I integrate?"

+
Sent docs link
+
+
+
@anon_whaleResolved
+

"privacy score?"

+
Score: 72/100
+
+
+
@new_userActioned
+

"send 1 SOL"

+
Execution link sent
+
+
+
+
+ + +
+
+
+
Talk to SIPHER...
+
⌘K
+
+
+ +
+
+ + diff --git a/app/designs/04-squad-view.html b/app/designs/04-squad-view.html new file mode 100644 index 0000000..0cd81e9 --- /dev/null +++ b/app/designs/04-squad-view.html @@ -0,0 +1,149 @@ + + + + + + + + + Squad | Guardian Command + + + + + + + + + +
+ +
+
Sipher
+ +
+ + +
+ + +
+
+
+
SIPHER
+ $2.14 +
+
Active · 3 sessions
+
+
+
+
HERALD
+ $1.87 +
+
Polling · next: 8m
+
+
+
+
SENTINEL
+ +
+
Scanning · next: 45s
+
+
+
+
COURIER
+ +
+
Idle · next op: 6h
+
+
+ + +
+

Today's Stats

+
+
47
Tool calls
+
3
Wallet sessions
+
2
X posts
+
8
X replies
+
2,841
Blocks scanned
+
1
Alerts
+
+
+ + +
+

Coordination (last 24h)

+
+
+
14:32
+
+
+
SENTINEL + +
SIPHER +
+
Unclaimed payment detected, notifying user
+
+
+
+
14:30
+
+
+
HERALD + +
SIPHER +
+
Running privacyScore for @dev_0x DM request
+
+
+
+
11:00
+
+
+
SENTINEL + +
COURIER +
+
Deposit #42 expired, triggering auto-refund
+
+
+
+
+ + + +
+ + +
+
+
+
Talk to SIPHER...
+
⌘K
+
+
+ +
+
+ + diff --git a/app/index.html b/app/index.html index 6a4e3ab..fdfe360 100644 --- a/app/index.html +++ b/app/index.html @@ -3,7 +3,12 @@ - Sipher — Privacy Agent + Guardian Command — Sipher + + + + +
diff --git a/app/package.json b/app/package.json index 7e7d2aa..87b673a 100644 --- a/app/package.json +++ b/app/package.json @@ -9,18 +9,20 @@ "preview": "vite preview" }, "dependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0", "@solana/wallet-adapter-react": "^0.15.0", "@solana/wallet-adapter-react-ui": "^0.9.0", "@solana/wallet-adapter-wallets": "^0.19.0", - "@solana/web3.js": "^1.98.0" + "@solana/web3.js": "^1.98.0", + "@tailwindcss/vite": "^4.2.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwindcss": "^4.2.2" }, "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", - "vite": "^6.0.0", "typescript": "^5.7.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0" + "vite": "^6.0.0" } } diff --git a/app/src/App.tsx b/app/src/App.tsx index ee2fa22..e00fb64 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,31 +1,51 @@ -import { useMemo } from 'react' +import { useState, useMemo } from 'react' import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react' import { WalletModalProvider } from '@solana/wallet-adapter-react-ui' import { PhantomWalletAdapter } from '@solana/wallet-adapter-wallets' import '@solana/wallet-adapter-react-ui/styles.css' import './styles/theme.css' -import WalletBar from './components/WalletBar' -import ChatContainer from './components/ChatContainer' +import Header from './components/Header' +import BottomNav from './components/BottomNav' +import CommandBar from './components/CommandBar' +import StreamView from './views/StreamView' +import VaultView from './views/VaultView' +import HeraldView from './views/HeraldView' +import SquadView from './views/SquadView' +import { useAuth } from './hooks/useAuth' +import { useSSE } from './hooks/useSSE' -const NETWORK = (import.meta.env.VITE_SOLANA_NETWORK ?? 'devnet') as 'devnet' | 'mainnet-beta' +type View = 'stream' | 'vault' | 'herald' | 'squad' +const NETWORK = (import.meta.env.VITE_SOLANA_NETWORK ?? 'mainnet-beta') as 'devnet' | 'mainnet-beta' const ENDPOINTS: Record = { devnet: 'https://api.devnet.solana.com', 'mainnet-beta': 'https://api.mainnet-beta.solana.com', } export default function App() { - const endpoint = import.meta.env.VITE_SOLANA_RPC_URL ?? ENDPOINTS[NETWORK] ?? ENDPOINTS.devnet + const endpoint = import.meta.env.VITE_SOLANA_RPC_URL ?? ENDPOINTS[NETWORK] const wallets = useMemo(() => [new PhantomWalletAdapter()], []) + const [activeView, setActiveView] = useState('stream') + const { token, authenticate, isAuthenticated } = useAuth() + const { events } = useSSE(token) return ( -
- - +
+
+
+ {activeView === 'stream' && } + {activeView === 'vault' && } + {activeView === 'herald' && } + {activeView === 'squad' && } +
+
+ + +
diff --git a/app/src/api/auth.ts b/app/src/api/auth.ts new file mode 100644 index 0000000..c03da95 --- /dev/null +++ b/app/src/api/auth.ts @@ -0,0 +1,16 @@ +import { apiFetch } from './client' + +export async function requestNonce(wallet: string): Promise<{ nonce: string, message: string }> { + return apiFetch('/api/auth/nonce', { method: 'POST', body: JSON.stringify({ wallet }) }) +} + +export async function verifySignature( + wallet: string, + nonce: string, + signature: string +): Promise<{ token: string, expiresIn: string }> { + return apiFetch('/api/auth/verify', { + method: 'POST', + body: JSON.stringify({ wallet, nonce, signature }), + }) +} diff --git a/app/src/api/client.ts b/app/src/api/client.ts new file mode 100644 index 0000000..ffb619b --- /dev/null +++ b/app/src/api/client.ts @@ -0,0 +1,21 @@ +const BASE = import.meta.env.VITE_API_URL ?? '' + +export async function apiFetch( + path: string, + options?: RequestInit & { token?: string } +): Promise { + const { token, ...fetchOpts } = options ?? {} + const headers: Record = { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + } + const res = await fetch(`${BASE}${path}`, { + ...fetchOpts, + headers: { ...headers, ...(fetchOpts.headers as Record) }, + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error((body as { error?: string }).error ?? `API error ${res.status}`) + } + return res.json() as Promise +} diff --git a/app/src/api/sse.ts b/app/src/api/sse.ts new file mode 100644 index 0000000..f4b94ab --- /dev/null +++ b/app/src/api/sse.ts @@ -0,0 +1,17 @@ +export type SSEHandler = (event: MessageEvent) => void + +export function connectSSE( + token: string, + onEvent: SSEHandler, + onError?: (err: Event) => void +): EventSource { + const url = `${import.meta.env.VITE_API_URL ?? ''}/api/stream?token=${encodeURIComponent(token)}` + const source = new EventSource(url) + source.addEventListener('activity', onEvent) + source.addEventListener('confirm', onEvent) + source.addEventListener('agent-status', onEvent) + source.addEventListener('herald-budget', onEvent) + source.addEventListener('cost-update', onEvent) + source.onerror = (err) => { onError?.(err) } + return source +} diff --git a/app/src/components/ActivityEntry.tsx b/app/src/components/ActivityEntry.tsx new file mode 100644 index 0000000..bde1c20 --- /dev/null +++ b/app/src/components/ActivityEntry.tsx @@ -0,0 +1,70 @@ +import { AGENTS, type AgentName } from '../lib/agents' +import { timeAgo } from '../lib/format' + +interface Action { + label: string + onClick: () => void +} + +interface Props { + agent: AgentName + title: string + detail?: string + time: string + level: string + actions?: Action[] +} + +export default function ActivityEntry({ agent, title, detail, time, level, actions }: Props) { + const agentConfig = AGENTS[agent] ?? { name: agent.toUpperCase(), color: '#71717A' } + const isCritical = level === 'critical' + + return ( +
+ {/* Top row: dot + agent name + time */} +
+
+
+ + {agentConfig.name} + +
+ {timeAgo(time)} +
+ + {/* Title */} +

{title}

+ + {/* Detail — monospace, for TX hashes, metrics, etc. */} + {detail && ( +

{detail}

+ )} + + {/* Actions */} + {actions && actions.length > 0 && ( +
+ {actions.map((action, i) => ( + + ))} +
+ )} +
+ ) +} diff --git a/app/src/components/AgentDot.tsx b/app/src/components/AgentDot.tsx new file mode 100644 index 0000000..cd8330c --- /dev/null +++ b/app/src/components/AgentDot.tsx @@ -0,0 +1,11 @@ +import { AGENTS, type AgentName } from '../lib/agents' + +export default function AgentDot({ agent, size = 6 }: { agent: AgentName, size?: number }) { + const color = AGENTS[agent].color + return ( +
+ ) +} diff --git a/app/src/components/BottomNav.tsx b/app/src/components/BottomNav.tsx new file mode 100644 index 0000000..7fd087d --- /dev/null +++ b/app/src/components/BottomNav.tsx @@ -0,0 +1,27 @@ +type View = 'stream' | 'vault' | 'herald' | 'squad' + +const TABS: { id: View, label: string, icon: string, activeIcon: string }[] = [ + { id: 'stream', label: 'Stream', icon: 'ph ph-waves', activeIcon: 'ph-fill ph-waves' }, + { id: 'vault', label: 'Vault', icon: 'ph ph-vault', activeIcon: 'ph-fill ph-vault' }, + { id: 'herald', label: 'HERALD', icon: 'ph ph-broadcast', activeIcon: 'ph-fill ph-broadcast' }, + { id: 'squad', label: 'Squad', icon: 'ph ph-users-three', activeIcon: 'ph-fill ph-users-three' }, +] + +export default function BottomNav({ active, onChange }: { active: View, onChange: (v: View) => void }) { + return ( + + ) +} diff --git a/app/src/components/ChatContainer.tsx b/app/src/components/ChatContainer.tsx deleted file mode 100644 index e3d0bff..0000000 --- a/app/src/components/ChatContainer.tsx +++ /dev/null @@ -1,394 +0,0 @@ -import { useState, useRef, useEffect, useCallback } from 'react' -import { useWallet } from '@solana/wallet-adapter-react' -import TextMessage from './TextMessage' -import ConfirmationPrompt, { type ConfirmationData, type ConfirmationStatus } from './ConfirmationPrompt' -import QuickActions from './QuickActions' -import { useTransactionSigner, type SignStatus } from '../hooks/useTransactionSigner' - -interface ChatMessage { - id: string - role: 'user' | 'agent' - content: string - timestamp: Date - error?: boolean -} - -interface ConfirmationMessage { - id: string - type: 'confirmation' - data: ConfirmationData - timestamp: Date -} - -type Message = ChatMessage | ConfirmationMessage - -function isConfirmation(msg: Message): msg is ConfirmationMessage { - return 'type' in msg && msg.type === 'confirmation' -} - -function generateId(): string { - return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -} - -const API_URL = '/api/chat' -const STREAM_URL = '/api/chat/stream' - -export default function ChatContainer() { - const { connected, publicKey } = useWallet() - const { signAndBroadcast } = useTransactionSigner() - const [messages, setMessages] = useState([]) - const [input, setInput] = useState('') - const [loading, setLoading] = useState(false) - const messagesEndRef = useRef(null) - const textareaRef = useRef(null) - - // Auto-scroll to bottom on new messages - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) - - // Auto-resize textarea - useEffect(() => { - const el = textareaRef.current - if (!el) return - el.style.height = 'auto' - el.style.height = `${Math.min(el.scrollHeight, 120)}px` - }, [input]) - - const addMessage = useCallback((msg: Message) => { - setMessages(prev => [...prev, msg]) - }, []) - - const updateConfirmation = useCallback((confirmId: string, patch: Partial) => { - setMessages(prev => - prev.map(msg => { - if (isConfirmation(msg) && msg.data.id === confirmId) { - return { ...msg, data: { ...msg.data, ...patch } } - } - return msg - }) - ) - }, []) - - const updateMessage = useCallback((id: string, patch: Partial) => { - setMessages(prev => - prev.map(msg => { - if (!isConfirmation(msg) && msg.id === id) { - return { ...msg, ...patch } - } - return msg - }) - ) - }, []) - - /** - * Try SSE streaming first — real-time token delivery. - * Returns true if streaming succeeded, false if it should fall back to POST. - */ - const tryStreamingChat = useCallback(async ( - chatHistory: { role: 'user' | 'assistant'; content: string }[], - placeholderId: string, - ): Promise => { - let res: Response - try { - res = await fetch(STREAM_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages: chatHistory, - wallet: publicKey?.toBase58() ?? null, - }), - }) - } catch { - return false // Network error — fall back to POST - } - - if (!res.ok || !res.body) return false - - const reader = res.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - let accumulated = '' - - try { - let finished = false - // eslint-disable-next-line no-constant-condition - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - // Keep the last incomplete line in the buffer - buffer = lines.pop() ?? '' - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed.startsWith('data: ')) continue - const payload = trimmed.slice(6) - - if (payload === '[DONE]') { finished = true; break } - - try { - const event = JSON.parse(payload) - - if (event.type === 'content_block_delta' && event.text) { - accumulated += event.text - updateMessage(placeholderId, { content: accumulated }) - } else if (event.type === 'message_complete') { - // Final content — ensure full text is set - if (event.content) { - updateMessage(placeholderId, { content: event.content }) - } - } else if (event.type === 'error') { - updateMessage(placeholderId, { - content: event.message ?? 'Stream error occurred.', - error: true, - }) - return true // Error delivered via stream — don't fall back - } - } catch { - // Malformed JSON line — skip it - } - } - if (finished) break - } - } finally { - reader.releaseLock() - } - - return true - }, [publicKey, updateMessage]) - - /** - * Fallback: POST to /api/chat and wait for full response. - */ - const postChat = useCallback(async ( - chatHistory: { role: 'user' | 'assistant'; content: string }[], - placeholderId: string, - ): Promise => { - const res = await fetch(API_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages: chatHistory, - wallet: publicKey?.toBase58() ?? null, - }), - }) - - if (!res.ok) { - throw new Error(`Agent responded with ${res.status}`) - } - - const data = await res.json() - const agentText = data.content ?? data.message ?? 'No response from agent.' - - updateMessage(placeholderId, { content: agentText }) - - // If the response includes a confirmation request, render it - if (data.confirmation) { - const confirmId = generateId() - addMessage({ - id: generateId(), - type: 'confirmation', - data: { - id: confirmId, - action: data.confirmation.action ?? 'Transaction', - amount: data.confirmation.amount, - fee: data.confirmation.fee, - recipient: data.confirmation.recipient, - serializedTx: data.confirmation.serializedTx, - status: 'pending', - }, - timestamp: new Date(), - }) - } - }, [publicKey, addMessage, updateMessage]) - - const sendToAgent = useCallback(async (userText: string) => { - const chatHistory = messages - .filter((m): m is ChatMessage => !isConfirmation(m)) - .map(m => ({ role: m.role === 'user' ? 'user' as const : 'assistant' as const, content: m.content })) - - chatHistory.push({ role: 'user', content: userText }) - - // Create a placeholder message for real-time streaming updates - const placeholderId = generateId() - addMessage({ - id: placeholderId, - role: 'agent', - content: '', - timestamp: new Date(), - }) - - setLoading(true) - - try { - // Try streaming first, fall back to POST on failure - const streamed = await tryStreamingChat(chatHistory, placeholderId) - if (!streamed) { - await postChat(chatHistory, placeholderId) - } - } catch { - updateMessage(placeholderId, { - content: 'Sipher agent is offline. Make sure the server is running on /api/chat.', - error: true, - }) - } finally { - setLoading(false) - } - }, [messages, publicKey, addMessage, updateMessage, tryStreamingChat, postChat]) - - const handleSend = useCallback((text?: string) => { - const msg = (text ?? input).trim() - if (!msg || loading) return - - addMessage({ - id: generateId(), - role: 'user', - content: msg, - timestamp: new Date(), - }) - - if (!text) setInput('') - - sendToAgent(msg) - }, [input, loading, addMessage, sendToAgent]) - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSend() - } - } - - const handleQuickAction = (message: string) => { - handleSend(message) - } - - const handleConfirm = (id: string) => { - updateConfirmation(id, { status: 'confirmed' as ConfirmationStatus }) - addMessage({ - id: generateId(), - role: 'agent', - content: 'Transaction confirmed (no on-chain transaction for this action).', - timestamp: new Date(), - }) - } - - const handleSign = useCallback(async (confirmId: string, serializedTx: string) => { - updateConfirmation(confirmId, { signStatus: 'signing' as SignStatus }) - - const result = await signAndBroadcast(serializedTx) - - if (result.signature) { - updateConfirmation(confirmId, { - status: 'confirmed', - signStatus: 'confirmed', - signature: result.signature, - }) - addMessage({ - id: generateId(), - role: 'agent', - content: `Transaction confirmed: ${result.signature}`, - timestamp: new Date(), - }) - } else { - updateConfirmation(confirmId, { - signStatus: 'error', - txError: result.error ?? 'Transaction failed', - }) - addMessage({ - id: generateId(), - role: 'agent', - content: `Transaction failed: ${result.error}`, - timestamp: new Date(), - error: true, - }) - } - - return result - }, [signAndBroadcast, updateConfirmation, addMessage]) - - const handleCancel = (id: string) => { - updateConfirmation(id, { status: 'cancelled' as ConfirmationStatus }) - addMessage({ - id: generateId(), - role: 'agent', - content: 'Transaction cancelled.', - timestamp: new Date(), - }) - } - - const isEmpty = messages.length === 0 - - return ( -
-
- {isEmpty ? ( -
-
{'\u{1f510}'}
-
Sipher Privacy Agent
-
- {connected - ? 'Ask me anything about private transfers, vault operations, or stealth payments.' - : 'Connect your wallet to get started.'} -
-
- ) : ( - <> - {messages.map(msg => - isConfirmation(msg) ? ( - - ) : ( - - ) - )} - {loading && ( -
-
-
-
-
- )} - - )} -
-
- - - -
-