Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ LOG_LEVEL=info
API_KEYS=dev-key-1,dev-key-2
ADMIN_API_KEY=

# Admin wallet allowlist (comma-separated base58 pubkeys)
AUTHORIZED_WALLETS=

# Sipher Agent (OpenRouter)
SIPHER_OPENROUTER_API_KEY=
OPENROUTER_API_KEY=
SIPHER_MODEL=anthropic/claude-sonnet-4-6

# Solana RPC
# Sipher public URL (used for payment links, invoices)
SIPHER_BASE_URL=https://sipher.sip-protocol.org

# Solana
SOLANA_NETWORK=mainnet-beta
SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
SOLANA_RPC_URL_FALLBACK=
RPC_PROVIDER=generic
Expand All @@ -24,12 +31,15 @@ SIPHER_HELIUS_API_KEY=
# Redis (optional — falls back to in-memory LRU)
REDIS_URL=

# Jito Block Engine (leave empty for mock mode)
# Mainnet: https://mainnet.block-engine.jito.wtf/api/v1/bundles
# Jupiter DEX API (defaults to free tier)
JUPITER_API_URL=https://lite-api.jup.ag

# Jito Block Engine (required for real bundle relay — mock mode when empty)
# Production: https://mainnet.block-engine.jito.wtf/api/v1/bundles
JITO_BLOCK_ENGINE_URL=

# Stripe (billing webhooks)
STRIPE_WEBHOOK_SECRET=whsec_sipher_dev_secret
STRIPE_WEBHOOK_SECRET=

# CORS
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
Expand Down
2 changes: 2 additions & 0 deletions app/.env.development
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:5006
VITE_SOLANA_NETWORK=devnet
2 changes: 2 additions & 0 deletions app/.env.production
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
VITE_API_URL=https://sipher.sip-protocol.org
VITE_SOLANA_NETWORK=mainnet-beta
10 changes: 8 additions & 2 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
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 {
PhantomWalletAdapter,
SolflareWalletAdapter,
} from '@solana/wallet-adapter-wallets'
import '@solana/wallet-adapter-react-ui/styles.css'
import './styles/theme.css'

Expand All @@ -25,7 +28,10 @@ const ENDPOINTS: Record<string, string> = {

export default function App() {
const endpoint = import.meta.env.VITE_SOLANA_RPC_URL ?? ENDPOINTS[NETWORK]
const wallets = useMemo(() => [new PhantomWalletAdapter()], [])
const wallets = useMemo(() => [
new PhantomWalletAdapter(),
new SolflareWalletAdapter(),
], [])
const [activeView, setActiveView] = useState<View>('stream')
const { token, authenticate, isAuthenticated } = useAuth()
const { events } = useSSE(token)
Expand Down
1 change: 1 addition & 0 deletions app/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_API_URL?: string
readonly VITE_SOLANA_NETWORK?: 'devnet' | 'mainnet-beta'
readonly VITE_SOLANA_RPC_URL?: string
}
Expand Down
98 changes: 98 additions & 0 deletions packages/agent/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@ CREATE TABLE IF NOT EXISTS agent_events (
created_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS conversations (
session_id TEXT NOT NULL,
seq INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (session_id, seq),
FOREIGN KEY (session_id) REFERENCES sessions(id)
);

CREATE INDEX IF NOT EXISTS idx_conversations_session ON conversations(session_id, seq);
CREATE INDEX IF NOT EXISTS idx_activity_wallet_created ON activity_stream(wallet, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_level ON activity_stream(level);
CREATE INDEX IF NOT EXISTS idx_herald_queue_status ON herald_queue(status, scheduled_at);
Expand Down Expand Up @@ -254,6 +265,93 @@ export function getSessionByWallet(wallet: string): Session | null {
return { ...row, preferences: JSON.parse(row.preferences) }
}

// ─────────────────────────────────────────────────────────────────────────────
// Conversations (persisted to SQLite)
// ─────────────────────────────────────────────────────────────────────────────

export interface ConversationRow {
session_id: string
seq: number
role: string
content: string
created_at: number
}

/** Load all conversation messages for a session, ordered by seq. */
export function loadConversation(sessionId: string): ConversationRow[] {
const conn = getDb()
return conn
.prepare('SELECT * FROM conversations WHERE session_id = ? ORDER BY seq ASC')
.all(sessionId) as ConversationRow[]
}

/** Append messages to a session's conversation. Returns the new seq values. */
export function appendConversationRows(
sessionId: string,
messages: { role: string; content: unknown }[],
maxMessages = 100,
): void {
const conn = getDb()
const now = Date.now()

// Get current max seq
const maxRow = conn
.prepare('SELECT COALESCE(MAX(seq), 0) AS max_seq FROM conversations WHERE session_id = ?')
.get(sessionId) as { max_seq: number }
let seq = maxRow.max_seq

const insert = conn.prepare(
'INSERT INTO conversations (session_id, seq, role, content, created_at) VALUES (?, ?, ?, ?, ?)',
)

const tx = conn.transaction(() => {
for (const msg of messages) {
seq++
const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
insert.run(sessionId, seq, msg.role, content, now)
}

// Trim to maxMessages — keep latest
const total = (conn
.prepare('SELECT COUNT(*) AS cnt FROM conversations WHERE session_id = ?')
.get(sessionId) as { cnt: number }).cnt

if (total > maxMessages) {
const cutoff = total - maxMessages
conn.prepare(
`DELETE FROM conversations WHERE session_id = ? AND seq IN (
SELECT seq FROM conversations WHERE session_id = ? ORDER BY seq ASC LIMIT ?
)`,
).run(sessionId, sessionId, cutoff)
}
})

tx()
}

/** Delete all conversation messages for a session. */
export function clearConversationRows(sessionId: string): void {
const conn = getDb()
conn.prepare('DELETE FROM conversations WHERE session_id = ?').run(sessionId)
}

/** Delete conversations idle longer than the given timeout (ms). Returns purged count. */
export function purgeStaleConversations(timeoutMs: number): number {
const conn = getDb()
const cutoff = Date.now() - timeoutMs
const result = conn.prepare(
'DELETE FROM conversations WHERE session_id IN (SELECT DISTINCT session_id FROM conversations GROUP BY session_id HAVING MAX(created_at) < ?)',
).run(cutoff)
return result.changes
}

/** Count distinct sessions with active conversations. */
export function activeConversationCount(): number {
const conn = getDb()
const row = conn.prepare('SELECT COUNT(DISTINCT session_id) AS cnt FROM conversations').get() as { cnt: number }
return row.cnt
}

// ─────────────────────────────────────────────────────────────────────────────
// Audit log
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
11 changes: 11 additions & 0 deletions packages/agent/src/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ CREATE TABLE IF NOT EXISTS payment_links (
created_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS conversations (
session_id TEXT NOT NULL,
seq INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (session_id, seq),
FOREIGN KEY (session_id) REFERENCES sessions(id)
);

CREATE INDEX IF NOT EXISTS idx_conversations_session ON conversations(session_id, seq);
CREATE INDEX IF NOT EXISTS idx_audit_session ON audit_log(session_id);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_scheduled_next ON scheduled_ops(next_exec, status);
Expand Down
78 changes: 75 additions & 3 deletions packages/agent/src/routes/vault-api.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,87 @@
import { Router, type Request, type Response } from 'express'
import { PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js'
import { TOKEN_PROGRAM_ID } from '@solana/spl-token'
import { createConnection, WSOL_MINT, USDC_MINT, USDT_MINT } from '@sipher/sdk'
import { getActivity } from '../db.js'

export const vaultRouter = Router()

// Reverse lookup: mint address → human-readable symbol
const MINT_LABELS: Record<string, string> = {
[WSOL_MINT.toBase58()]: 'SOL',
[USDC_MINT.toBase58()]: 'USDC',
[USDT_MINT.toBase58()]: 'USDT',
}

interface TokenBalance {
mint: string
symbol: string
amount: string
decimals: number
uiAmount: number
}

/**
* GET /api/vault
* Returns the authenticated wallet's recent activity (last 20 entries).
* Returns the authenticated wallet's on-chain balances and recent activity.
* Requires verifyJwt middleware upstream — wallet is attached to req by it.
*/
vaultRouter.get('/', (req: Request, res: Response) => {
vaultRouter.get('/', async (req: Request, res: Response) => {
const wallet = (req as unknown as Record<string, unknown>).wallet as string
const network = (process.env.SOLANA_NETWORK ?? 'mainnet-beta') as 'devnet' | 'mainnet-beta'
const connection = createConnection(network)

let solBalance = 0
const tokens: TokenBalance[] = []

try {
const pubkey = new PublicKey(wallet)

// Fetch native SOL balance
const lamports = await connection.getBalance(pubkey)
solBalance = lamports / LAMPORTS_PER_SOL

// Fetch SPL token accounts
const tokenAccounts = await connection.getTokenAccountsByOwner(pubkey, {
programId: TOKEN_PROGRAM_ID,
})

for (const { account } of tokenAccounts.value) {
// SPL token account data layout: mint(32) + owner(32) + amount(8) + ...
const data = account.data
const mint = new PublicKey(data.subarray(0, 32))
const mintStr = mint.toBase58()
const rawAmount = data.readBigUInt64LE(64)

// Skip zero-balance accounts and wrapped SOL (shown as native SOL above)
if (rawAmount === 0n || mint.equals(WSOL_MINT)) continue

const symbol = MINT_LABELS[mintStr] ?? mintStr.slice(0, 8) + '...'
const decimals = mint.equals(USDC_MINT) || mint.equals(USDT_MINT) ? 6 : 9
const uiAmount = Number(rawAmount) / 10 ** decimals

tokens.push({
mint: mintStr,
symbol,
amount: rawAmount.toString(),
decimals,
uiAmount,
})
}
} catch (err) {
// RPC failures should not block the entire response — return what we have
console.warn('[vault] balance fetch failed:', err instanceof Error ? err.message : err)
}

const activity = getActivity(wallet, { limit: 20 })
res.json({ wallet, activity })

res.json({
wallet,
network,
balances: {
sol: solBalance,
tokens,
},
activity,
})
})
Loading
Loading