release: prepare v0.9.7

This commit is contained in:
BigBodyCobain
2026-05-01 22:55:04 -06:00
parent ea457f27da
commit 28b3bd5ebf
670 changed files with 187060 additions and 14006 deletions
@@ -0,0 +1,599 @@
'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { ArrowLeft, Send, MapPin, Loader2, Brain, Trash2, Sparkles, Link2, Copy, Check, X } from 'lucide-react';
import { getBackendEndpoint } from '@/lib/backendEndpoint';
interface AIMessage {
role: 'user' | 'ai' | 'system';
content: string;
timestamp: number;
pins?: { lat: number; lng: number; label: string }[];
}
interface AIQueryViewProps {
onBack: () => void;
}
const EXAMPLE_QUERIES = [
'What military flights are active right now?',
'Show me recent earthquakes over magnitude 4',
'What ships are near the Taiwan Strait?',
'Give me a threat level assessment',
'What are the top prediction market movers?',
'Are there any correlation alerts?',
'Show me satellite imagery of Tehran',
'Get news from Ukraine',
'What SIGINT activity is happening?',
'Place a pin on every military base near Denver',
];
export default function AIQueryView({ onBack }: AIQueryViewProps) {
const [messages, setMessages] = useState<AIMessage[]>([
{
role: 'system',
content: `🌍📡 SHADOWBROKER AI CO-PILOT ONLINE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Connected to ShadowBroker OSINT platform.
I can query telemetry, place pins on the map,
search satellite imagery, aggregate news,
and access all 30+ data layers.
Type a question or command to get started.
Use "help" to see capabilities.`,
timestamp: Date.now(),
},
]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showConnect, setShowConnect] = useState(false);
const [copied, setCopied] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const apiEndpoint = getBackendEndpoint();
const handleCopy = useCallback((text: string) => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
inputRef.current?.focus();
}, []);
const processQuery = useCallback(async (query: string) => {
const lowerQuery = query.toLowerCase().trim();
// Handle built-in commands
if (lowerQuery === 'help') {
return {
content: `🌍🔍 AVAILABLE COMMANDS:
━━━━━━━━━━━━━━━━━━━━━━━━━━━
TELEMETRY QUERIES:
• "military flights" — Active military aircraft
• "ships" — Tracked vessels
• "satellites" — Orbital assets
• "earthquakes" — Recent seismic activity
• "threat level" — Current threat assessment
• "prediction markets" — Market consensus data
• "sigint" — RF signal intelligence totals
• "correlations" — Cross-layer alerts
INTELLIGENCE:
• "report" — Full intelligence report
• "summary" — Quick telemetry summary
• "news summary" — AI news brief with top stories & trends
• "correlations" — Explain cross-layer correlation alerts
• "news [place]" — News near a location
• "satellite images [place]" — Sentinel-2 imagery
PIN COMMANDS:
• "pin [lat] [lng] [label]" — Place a pin
• "clear pins" — Clear all AI pins
• "list pins" — Show current pins
TIME MACHINE:
• "snapshot" — Take a telemetry snapshot
• "snapshots" — List available snapshots
• "timemachine config" — View snapshot settings
SYSTEM:
• "status" — AI system status
• "clear" — Clear chat history
• "help" — This message`,
};
}
if (lowerQuery === 'clear') {
setMessages([{
role: 'system',
content: '🌍✅ Chat cleared. Ready for queries.',
timestamp: Date.now(),
}]);
return null;
}
// API queries
try {
const base = '/api/ai';
if (lowerQuery === 'status') {
const resp = await fetch(`${base}/status`);
const data = await resp.json();
return {
content: `🌍✅ SHADOWBROKER AI STATUS:
━━━━━━━━━━━━━━━━━━━━━━━━━━━
Status: ${data.status || 'ONLINE'}
Capabilities: ${(data.capabilities || []).join(', ')}
Pin Count: ${data.pin_count ?? 'N/A'}
Version: ${data.version || '1.0'}`,
};
}
if (lowerQuery === 'summary' || lowerQuery === 'quick summary') {
const resp = await fetch(`${base}/summary`);
const data = await resp.json();
const counts = data.layer_counts || {};
const lines = Object.entries(counts)
.filter(([, v]) => (v as number) > 0)
.map(([k, v]) => `${k}: ${v}`)
.join('\n');
return {
content: `🌍📡 TELEMETRY SUMMARY:
━━━━━━━━━━━━━━━━━━━━━━━━━━━
${lines || ' No active telemetry data.'}
Threat Level: ${data.threat_level || 'N/A'}
SIGINT Totals: ${JSON.stringify(data.sigint_totals || {}, null, 0)}`,
};
}
if (lowerQuery === 'report' || lowerQuery === 'intelligence report') {
const resp = await fetch(`${base}/report`);
const data = await resp.json();
return {
content: `🌍🛰️ INTELLIGENCE REPORT:
━━━━━━━━━━━━━━━━━━━━━━━━━━━
${data.report || JSON.stringify(data, null, 2)}`,
};
}
if (lowerQuery.startsWith('pin ')) {
const parts = lowerQuery.replace('pin ', '').split(/\s+/);
if (parts.length >= 3) {
const lat = parseFloat(parts[0]);
const lng = parseFloat(parts[1]);
const label = parts.slice(2).join(' ');
if (!isNaN(lat) && !isNaN(lng)) {
const resp = await fetch(`${base}/pins`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lng, label, category: 'research' }),
});
const data = await resp.json();
return {
content: `🌍📌 SHADOWBROKER PINNING:
Pin placed successfully!
📍 ${lat.toFixed(4)}°, ${lng.toFixed(4)}°
🏷️ ${label}
🆔 ${data.pin_id || 'assigned'}`,
pins: [{ lat, lng, label }],
};
}
}
return { content: '❌ Usage: pin [latitude] [longitude] [label]' };
}
if (lowerQuery === 'list pins' || lowerQuery === 'pins') {
const resp = await fetch(`${base}/pins`);
const data = await resp.json();
const pinList = (data.pins || [])
.slice(0, 20)
.map((p: { label: string; lat: number; lng: number; category: string }) =>
` 📍 ${p.label} (${p.lat.toFixed(2)}°, ${p.lng.toFixed(2)}°) [${p.category}]`
)
.join('\n');
return {
content: `🌍📌 AI INTEL PINS (${data.count || 0}):
━━━━━━━━━━━━━━━━━━━━━━━━━━━
${pinList || ' No pins placed yet.'}`,
};
}
if (lowerQuery === 'clear pins') {
await fetch(`${base}/pins`, { method: 'DELETE' });
return { content: '🌍❌ SHADOWBROKER CLEARING:\nAll AI intel pins cleared.' };
}
if (lowerQuery === 'snapshot' || lowerQuery === 'take snapshot') {
const resp = await fetch(`${base}/timemachine/snapshot`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await resp.json();
return {
content: `🌍🕰️ SHADOWBROKER TIMEMACHINE:
Snapshot taken!
🆔 ${data.snapshot_id}
🕐 ${data.timestamp}
📊 Layers: ${(data.layers || []).join(', ')}`,
};
}
if (lowerQuery === 'snapshots' || lowerQuery === 'list snapshots') {
const resp = await fetch(`${base}/timemachine/snapshots`);
const data = await resp.json();
const snapList = (data.snapshots || [])
.slice(0, 10)
.map((s: { id: string; timestamp: string; layers: string[] }) =>
` 🗂️ ${s.id}${s.timestamp} (${s.layers.length} layers)`
)
.join('\n');
return {
content: `🌍🕰️ TIME MACHINE SNAPSHOTS (${data.count || 0}):
━━━━━━━━━━━━━━━━━━━━━━━━━━━
${snapList || ' No snapshots taken yet.'}`,
};
}
if (lowerQuery === 'timemachine config' || lowerQuery === 'tm config') {
const resp = await fetch(`${base}/timemachine/config`);
const data = await resp.json();
const cfg = data.config || {};
return {
content: `🌍🕰️ TIME MACHINE CONFIG:
━━━━━━━━━━━━━━━━━━━━━━━━━━━
Preset: ${cfg.preset || 'active'}
High-Frequency (${cfg.profiles?.high_freq?.interval_minutes || 15}min):
${(cfg.profiles?.high_freq?.layers || []).join(', ')}
Standard (${cfg.profiles?.standard?.interval_minutes || 120}min):
${(cfg.profiles?.standard?.layers || []).join(', ')}
Available presets: paranoid (5min), active (15min), casual (1hr), minimal (6hr)`,
};
}
if (lowerQuery === 'news summary' || lowerQuery === 'news brief' || lowerQuery === 'ai brief') {
const resp = await fetch(`${base}/news/summary`);
const data = await resp.json();
const topStories = (data.top_stories || [])
.slice(0, 5)
.map((s: { risk_score: number; title: string; source: string }) =>
` [${s.risk_score}/10] ${s.title}${s.source}`
)
.join('\n');
const keywords = (data.keywords || [])
.slice(0, 8)
.map((kw: { word: string; count: number }) => `${kw.word}(${kw.count})`)
.join(', ');
const td = data.threat_distribution || {};
return {
content: `🌍📰 AI INTELLIGENCE BRIEF:
━━━━━━━━━━━━━━━━━━━━━━━━━━━
${data.summary || 'No data available.'}
TOP STORIES:
${topStories || ' None available.'}
TRENDING: ${keywords || 'N/A'}
THREAT DISTRIBUTION:
🔴 CRITICAL: ${td.CRITICAL || 0} 🟠 HIGH: ${td.HIGH || 0} 🟡 ELEVATED: ${td.ELEVATED || 0}
🔵 MODERATE: ${td.MODERATE || 0} 🟢 LOW: ${td.LOW || 0}`,
};
}
if (lowerQuery === 'correlations' || lowerQuery === 'explain correlations' || lowerQuery === 'correlation alerts') {
const resp = await fetch(`${base}/correlations/explain`);
const data = await resp.json();
if (!data.count) {
return { content: '🌍⚡ CORRELATIONS:\nNo cross-layer correlation alerts are currently active.' };
}
const alerts = (data.explanations || [])
.slice(0, 8)
.map((e: { label: string; severity_text: string; driver_summary: string; implications: string[]; recommended_action: string; lat: number; lng: number }) =>
`━━━━━━━━━━━━━━━━━━━━━━━━━━━
📍 ${e.label}
Location: ${e.lat.toFixed(2)}°, ${e.lng.toFixed(2)}°
Severity: ${e.severity_text}
Indicators: ${e.driver_summary}
Assessment: ${e.implications?.[0] || 'N/A'}
Action: ${e.recommended_action}`
)
.join('\n');
return {
content: `🌍⚡ CORRELATION ANALYSIS (${data.count} alerts):
${data.summary || ''}
${alerts}`,
};
}
// Generic fallback — try summary
return {
content: `🌍🔍 SHADOWBROKER SEARCHING:
Processing query: "${query}"
I can directly execute these commands:
• summary / report / status
• pin [lat] [lng] [label]
• list pins / clear pins
• snapshot / snapshots / timemachine config
• help
For complex queries (natural language research, web search,
multi-step investigations), connect OpenClaw with an LLM
provider to unlock full agent capabilities.
Type "help" for the full command list.`,
};
} catch (error) {
return {
content: `🌍⚠️ SHADOWBROKER WARNING:
Query failed: ${error instanceof Error ? error.message : 'Unknown error'}
Make sure the ShadowBroker backend is running on localhost:8000.`,
};
}
}, []);
const handleSubmit = useCallback(async () => {
const query = input.trim();
if (!query || isLoading) return;
setInput('');
setMessages(prev => [...prev, { role: 'user', content: query, timestamp: Date.now() }]);
setIsLoading(true);
const result = await processQuery(query);
if (result) {
setMessages(prev => [...prev, {
role: 'ai',
content: result.content,
timestamp: Date.now(),
pins: result.pins,
}]);
}
setIsLoading(false);
}, [input, isLoading, processQuery]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
};
return (
<div className="h-full flex flex-col bg-[#0a0a0a] text-gray-300">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-purple-900/40 bg-purple-950/10 shrink-0">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="text-gray-500 hover:text-gray-300 transition-colors"
title="Back to terminal"
>
<ArrowLeft size={18} />
</button>
<Brain size={18} className="text-purple-400" />
<span className="text-sm tracking-[0.2em] text-purple-400 uppercase font-bold">
AI Co-Pilot
</span>
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse shadow-[0_0_6px_rgba(34,197,94,0.6)]" />
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowConnect(!showConnect)}
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-bold tracking-wider uppercase transition-all rounded-sm ${
showConnect
? 'bg-purple-900/40 border border-purple-500/50 text-purple-300'
: 'bg-purple-900/20 border border-purple-800/30 text-purple-500 hover:bg-purple-900/30 hover:text-purple-300 hover:border-purple-600/40'
}`}
title="Connect your OpenClaw agent"
>
<Link2 size={13} />
Connect OpenClaw
</button>
<button
onClick={() => setMessages([{
role: 'system',
content: '🌍✅ Chat cleared. Ready for queries.',
timestamp: Date.now(),
}])}
className="text-gray-600 hover:text-red-400 transition-colors"
title="Clear chat"
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* Connect OpenClaw Panel */}
{showConnect && (
<div className="border-b border-purple-900/40 bg-purple-950/15 px-4 py-4 shrink-0 overflow-y-auto max-h-[60vh]">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Link2 size={14} className="text-purple-400" />
<span className="text-sm font-bold tracking-wider text-purple-400 uppercase">Connect Your OpenClaw Agent</span>
</div>
<button onClick={() => setShowConnect(false)} className="text-gray-600 hover:text-gray-300 transition-colors">
<X size={14} />
</button>
</div>
<div className="space-y-3 text-sm font-mono">
{/* API Endpoint */}
<div>
<div className="text-[11px] text-gray-500 uppercase tracking-widest mb-1">Your ShadowBroker API Endpoint</div>
<div className="flex items-center gap-2">
<code className="flex-1 bg-black/60 border border-purple-800/40 px-3 py-2 text-purple-300 text-sm rounded-sm select-all">
{apiEndpoint}
</code>
<button
onClick={() => handleCopy(apiEndpoint)}
className="p-2 bg-purple-900/30 border border-purple-800/40 text-purple-400 hover:bg-purple-900/50 hover:text-purple-200 transition-colors rounded-sm"
title="Copy endpoint"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
</div>
{/* Setup Instructions */}
<div>
<div className="text-[11px] text-gray-500 uppercase tracking-widest mb-1">Setup Instructions</div>
<div className="bg-black/60 border border-gray-800/40 rounded-sm p-3 space-y-2 text-[13px] leading-relaxed">
<p className="text-cyan-400 font-bold">Step 1: Install the ShadowBroker Skill</p>
<p className="text-gray-400">Copy the <code className="text-purple-300 bg-purple-900/30 px-1">openclaw-skills/shadowbroker/</code> folder into your OpenClaw&apos;s skills directory.</p>
<p className="text-cyan-400 font-bold mt-2">Step 2: Configure the API Endpoint</p>
<p className="text-gray-400">Tell your OpenClaw agent to connect to:</p>
<code className="block bg-purple-950/40 border border-purple-800/30 px-2 py-1 text-purple-300 text-[13px] rounded-sm">
SHADOWBROKER_URL={apiEndpoint}
</code>
<p className="text-cyan-400 font-bold mt-2">Step 3: Tell Your Agent</p>
<p className="text-gray-400">Paste this into your OpenClaw&apos;s system prompt or instructions:</p>
<div className="relative">
<pre className="bg-purple-950/40 border border-purple-800/30 px-2 py-2 text-[12px] text-purple-200 rounded-sm overflow-x-auto whitespace-pre-wrap">{`You have a skill called "shadowbroker" that connects you to a real-time global OSINT intelligence platform. Use it to:
- Query military flights, ships, satellites, SIGINT, earthquakes, and 30+ data layers
- Place intelligence pins on a live map
- Fetch satellite imagery from Sentinel-2
- Aggregate news by region via GDELT
- Take telemetry snapshots (Time Machine)
- Participate in the Wormhole encrypted mesh network
- Send/receive InfoNet messages via decentralized feed
API: ${apiEndpoint}
Skill docs: openclaw-skills/shadowbroker/SKILL.md`}</pre>
<button
onClick={() => handleCopy(`You have a skill called "shadowbroker" that connects you to a real-time global OSINT intelligence platform. Use it to:\n- Query military flights, ships, satellites, SIGINT, earthquakes, and 30+ data layers\n- Place intelligence pins on a live map\n- Fetch satellite imagery from Sentinel-2\n- Aggregate news by region via GDELT\n- Take telemetry snapshots (Time Machine)\n- Participate in the Wormhole encrypted mesh network\n- Send/receive InfoNet messages via decentralized feed\n\nAPI: ${apiEndpoint}\nSkill docs: openclaw-skills/shadowbroker/SKILL.md`)}
className="absolute top-1 right-1 p-1 bg-purple-900/50 text-purple-400 hover:text-purple-200 transition-colors rounded-sm"
title="Copy instructions"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
</div>
</div>
</div>
{/* Available Capabilities */}
<div>
<div className="text-[11px] text-gray-500 uppercase tracking-widest mb-1">Available Capabilities</div>
<div className="grid grid-cols-2 gap-1">
{[
['📡', 'Telemetry Queries'],
['📌', 'Pin Placement'],
['🛰️', 'Satellite Imagery'],
['📰', 'News Aggregation'],
['🕰️', 'Time Machine'],
['🔗', 'Wormhole Network'],
['📻', 'Meshtastic Radio'],
['💉', 'Data Injection'],
['⚡', 'Correlation Analysis'],
['🚨', 'Alert Dispatch'],
].map(([emoji, label]) => (
<div key={label} className="flex items-center gap-1.5 text-[12px] text-gray-400 bg-black/30 border border-gray-800/30 px-2 py-1 rounded-sm">
<span>{emoji}</span>
<span>{label}</span>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] px-3 py-2.5 text-[13px] leading-relaxed whitespace-pre-wrap ${
msg.role === 'user'
? 'bg-purple-900/30 border border-purple-700/40 text-purple-100'
: msg.role === 'system'
? 'bg-cyan-950/20 border border-cyan-900/30 text-cyan-300'
: 'bg-gray-900/40 border border-gray-800/40 text-gray-300'
}`}
>
{msg.content}
{msg.pins && msg.pins.length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-700/30 flex items-center gap-1.5 text-green-400 text-sm">
<MapPin size={12} />
<span>{msg.pins.length} pin(s) placed on map</span>
</div>
)}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-900/40 border border-gray-800/40 px-3 py-2.5 flex items-center gap-2 text-sm text-gray-500">
<Loader2 size={14} className="animate-spin" />
<span>Processing query...</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Quick suggestions */}
{messages.length <= 2 && (
<div className="px-4 pb-2 flex flex-wrap gap-1.5">
{EXAMPLE_QUERIES.slice(0, 4).map((q, i) => (
<button
key={i}
onClick={() => { setInput(q); inputRef.current?.focus(); }}
className="text-xs px-2.5 py-1 bg-purple-900/15 border border-purple-800/30 text-purple-400 hover:bg-purple-900/30 hover:text-purple-300 transition-colors flex items-center gap-1.5 rounded-sm"
>
<Sparkles size={10} />
{q}
</button>
))}
</div>
)}
{/* Input */}
<div className="shrink-0 px-4 py-3 border-t border-purple-900/30 bg-purple-950/5">
<div className="flex items-center gap-2">
<div className="flex-1 flex items-center bg-gray-900/40 border border-gray-700/40 focus-within:border-purple-700/60 transition-colors rounded-sm">
<span className="text-purple-500 text-sm px-2.5 select-none"></span>
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask anything... (type 'help' for commands)"
className="flex-1 bg-transparent border-none outline-none text-white text-sm py-2.5 pr-2 placeholder-gray-600 focus:ring-0"
disabled={isLoading}
spellCheck={false}
autoComplete="off"
/>
</div>
<button
onClick={handleSubmit}
disabled={isLoading || !input.trim()}
className="p-2 bg-purple-900/30 border border-purple-700/40 text-purple-400 hover:bg-purple-900/50 hover:text-purple-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Send size={14} />
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,337 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { ChevronLeft, Cpu, Loader, AlertCircle, CheckCircle2, XCircle, Server } from 'lucide-react';
import {
buildBootstrapResolutionVotePayload,
fetchBootstrapMarketState,
fetchInfonetStatus,
type BootstrapMarketState,
type InfonetStatus,
} from '@/mesh/infonetEconomyClient';
import { generateNodeKeys, getNodeIdentity } from '@/mesh/meshIdentity';
import {
DEFAULT_INFONET_SEED_URL,
fetchInfonetNodeStatusSnapshot,
setInfonetNodeEnabled,
type InfonetNodeStatusSnapshot,
} from '@/mesh/controlPlaneStatusClient';
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
interface BootstrapViewProps {
marketId?: string;
onBack: () => void;
}
export default function BootstrapView({ marketId, onBack }: BootstrapViewProps) {
const [status, setStatus] = useState<InfonetStatus | null>(null);
const [market, setMarket] = useState<BootstrapMarketState | null>(null);
const [nodeStatus, setNodeStatus] = useState<InfonetNodeStatusSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [nodeToggleBusy, setNodeToggleBusy] = useState(false);
const [nodeToggleError, setNodeToggleError] = useState<string | null>(null);
const [voteSide, setVoteSide] = useState<'yes' | 'no'>('yes');
const [powNonce, setPowNonce] = useState('0');
const voteAction = useSignAndAppend();
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [s, m, n] = await Promise.all([
fetchInfonetStatus(),
marketId ? fetchBootstrapMarketState(marketId).catch(() => null) : Promise.resolve(null),
fetchInfonetNodeStatusSnapshot(true).catch(() => null),
]);
setStatus(s);
setMarket(m);
setNodeStatus(n);
} catch (err) {
setError(err instanceof Error ? err.message : 'network error');
} finally {
setLoading(false);
}
}, [marketId]);
const nodeEnabled = Boolean(nodeStatus?.node_enabled);
const nodeMode = String(nodeStatus?.node_mode || 'participant').toUpperCase();
const syncOutcome = String(nodeStatus?.sync_runtime?.last_outcome || 'idle').toLowerCase();
const seedPeerCount = Number(nodeStatus?.bootstrap?.default_sync_peer_count || 0);
const syncPeerCount = Number(nodeStatus?.bootstrap?.sync_peer_count || 0);
const lastPeerUrl = String(nodeStatus?.sync_runtime?.last_peer_url || '').trim();
const toggleNode = useCallback(async (enabled: boolean) => {
setNodeToggleBusy(true);
setNodeToggleError(null);
try {
if (enabled && !getNodeIdentity()) {
await generateNodeKeys();
}
await setInfonetNodeEnabled(enabled);
const next = await fetchInfonetNodeStatusSnapshot(true);
setNodeStatus(next);
} catch (err) {
setNodeToggleError(err instanceof Error ? err.message : 'node settings update failed');
} finally {
setNodeToggleBusy(false);
}
}, []);
const hasActivePhase = !!market && market.tally.total_eligible >= 0
&& market.tally.yes + market.tally.no < market.tally.total_eligible;
useEffect(() => {
void reload();
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 30_000);
return () => clearInterval(interval);
}, [reload, hasActivePhase]);
const submitVote = useCallback(async () => {
if (!marketId) return;
const nonce = Number(powNonce);
if (!Number.isFinite(nonce) || nonce < 0) return;
const built = buildBootstrapResolutionVotePayload(marketId, voteSide, Math.floor(nonce));
const res = await voteAction.submit(built.event_type, built.payload);
if (res.ok) {
void reload();
}
}, [marketId, voteSide, powNonce, voteAction, reload]);
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
<button onClick={onBack} className="flex items-center text-cyan-400 hover:text-cyan-300 text-sm">
<ChevronLeft size={14} className="mr-1" /> BACK
</button>
<div className="text-sm text-cyan-400 font-bold uppercase tracking-widest flex items-center gap-2">
<Cpu size={16} /> BOOTSTRAP MODE
</div>
<button onClick={() => void reload()} disabled={loading} className="text-xs text-gray-500 hover:text-cyan-400 disabled:opacity-30">
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
</button>
</div>
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
<div className="text-xs text-gray-500 leading-relaxed">
The first <span className="text-cyan-400">bootstrap_market_count</span> (default 100) markets
resolve via <span className="text-cyan-400">eligible-node-one-vote</span> instead of oracle-rep-weighted
staking. Eligibility: identity age 3 days vs market.snapshot.frozen_at,
NOT in the predictor exclusion set, and a valid Argon2id PoW
(Heavy-Node-only requires 64MB RAM per computation).
Once node count crosses <span className="text-cyan-400">bootstrap_threshold</span> (default 1000),
new markets default to staked resolution. Existing bootstrap-indexed markets continue under
bootstrap rules until they resolve.
</div>
{error && (
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
<AlertCircle size={12} className="inline mr-1" />{error}
</div>
)}
<div className="border border-cyan-900/50 bg-cyan-950/10 p-3">
<div className="flex items-center justify-between gap-3 mb-3">
<div className="text-xs uppercase tracking-wider text-cyan-400 flex items-center gap-2">
<Server size={14} /> Network Seed
</div>
<button
type="button"
onClick={() => void reload()}
disabled={loading}
className="text-[10px] text-gray-500 hover:text-cyan-400 disabled:opacity-30 uppercase tracking-widest"
>
Refresh
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
<div>
<div className="text-gray-500">Default Seed</div>
<div className="text-cyan-300 font-mono break-all">{DEFAULT_INFONET_SEED_URL}</div>
</div>
<div>
<div className="text-gray-500">Local Node</div>
<div className={nodeEnabled ? 'text-green-400' : 'text-gray-500'}>
{nodeEnabled ? `${nodeMode} ONLINE` : `${nodeMode} OFF`}
</div>
</div>
<div>
<div className="text-gray-500">Sync Path</div>
<div className="text-white font-mono">
{syncPeerCount} peers / {seedPeerCount} default
</div>
</div>
</div>
<div className="mt-3 flex flex-col md:flex-row md:items-center gap-3">
<div className="flex-1 text-[11px] text-gray-500 leading-relaxed">
{nodeEnabled
? `Public chain sync is ${syncOutcome || 'active'}${lastPeerUrl ? ` via ${lastPeerUrl}` : ''}.`
: 'Start a local participant node to pull from the default seed and help carry the public Infonet chain while this backend is running.'}
</div>
<button
type="button"
onClick={() => void toggleNode(!nodeEnabled)}
disabled={nodeToggleBusy}
className={
nodeEnabled
? 'px-3 py-2 border border-rose-700/50 bg-rose-950/20 text-rose-300 hover:bg-rose-950/35 disabled:opacity-40 text-[10px] uppercase tracking-wider'
: 'px-3 py-2 border border-cyan-700/50 bg-cyan-900/20 text-cyan-300 hover:bg-cyan-900/40 disabled:opacity-40 text-[10px] uppercase tracking-wider'
}
>
{nodeToggleBusy ? 'Updating...' : nodeEnabled ? 'Turn Off Node' : 'Start Node'}
</button>
</div>
{nodeToggleError && (
<div className="mt-3 border border-amber-900/50 bg-amber-950/20 p-2 text-[11px] text-amber-300">
<AlertCircle size={11} className="inline mr-1" />{nodeToggleError}
</div>
)}
</div>
{status && (
<div className="border border-gray-800 bg-black/40 p-3">
<div className="text-xs uppercase tracking-wider text-cyan-400 mb-2">Network Ramp</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
<div>
<div className="text-gray-500">Distinct Nodes</div>
<div className="text-white font-mono text-lg">{status.ramp.node_count}</div>
</div>
<div>
<div className="text-gray-500">Bootstrap Resolution</div>
<div className={status.ramp.bootstrap_resolution_active ? 'text-green-400' : 'text-gray-500'}>
{status.ramp.bootstrap_resolution_active ? 'ACTIVE' : 'TRANSITIONED'}
</div>
</div>
<div>
<div className="text-gray-500">Staked Resolution</div>
<div className={status.ramp.staked_resolution_active ? 'text-green-400' : 'text-gray-500'}>
{status.ramp.staked_resolution_active ? 'ACTIVE' : 'LOCKED'}
</div>
</div>
<div>
<div className="text-gray-500">Petitions</div>
<div className={status.ramp.governance_petitions_active ? 'text-green-400' : 'text-gray-500'}>
{status.ramp.governance_petitions_active ? 'ACTIVE' : 'LOCKED'}
</div>
</div>
<div>
<div className="text-gray-500">Upgrade Governance</div>
<div className={status.ramp.upgrade_governance_active ? 'text-green-400' : 'text-gray-500'}>
{status.ramp.upgrade_governance_active ? 'ACTIVE' : 'LOCKED'}
</div>
</div>
<div>
<div className="text-gray-500">CommonCoin</div>
<div className={status.ramp.commoncoin_active ? 'text-green-400' : 'text-gray-500'}>
{status.ramp.commoncoin_active ? 'ACTIVE' : 'LOCKED'}
</div>
</div>
</div>
</div>
)}
{market && (
<div className="border border-gray-800 bg-black/40 p-3">
<div className="text-xs uppercase tracking-wider text-cyan-400 mb-2">
Market: <span className="font-mono text-white">{market.market_id}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs mb-3">
<div>
<div className="text-gray-500">YES votes</div>
<div className="text-green-400 font-mono text-lg">{market.tally.yes}</div>
</div>
<div>
<div className="text-gray-500">NO votes</div>
<div className="text-red-400 font-mono text-lg">{market.tally.no}</div>
</div>
<div>
<div className="text-gray-500">Total Eligible</div>
<div className="text-white font-mono text-lg">{market.tally.total_eligible}</div>
</div>
<div>
<div className="text-gray-500">Min Required</div>
<div className="text-gray-300 font-mono text-lg">{market.tally.min_market_participants}</div>
</div>
</div>
<div className="border border-cyan-900/50 bg-cyan-900/10 p-2 mb-3 text-xs">
<div className="text-cyan-400 font-bold uppercase tracking-wider mb-2">
Cast Bootstrap Vote
</div>
<div className="text-gray-500 mb-2">
Eligibility: identity age {' '}
{status ? '3 days' : 'configured threshold'}{' '}
vs market.snapshot.frozen_at, NOT in predictor exclusion set,
and a valid Argon2id PoW (Heavy-Node-only). The PoW nonce
input is for testnet production wires the Argon2id solver
via privacy-core when the Rust binding lands.
</div>
<div className="flex flex-wrap items-center gap-2">
<select
value={voteSide}
onChange={(e) => setVoteSide(e.target.value as 'yes' | 'no')}
title="Bootstrap vote side"
aria-label="Bootstrap vote side"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
>
<option value="yes">YES</option>
<option value="no">NO</option>
</select>
<input
type="number"
min="0"
step="1"
value={powNonce}
onChange={(e) => setPowNonce(e.target.value)}
placeholder="pow_nonce"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-32"
/>
<button
type="button"
onClick={submitVote}
disabled={voteAction.state === 'submitting' || !marketId}
className="px-3 py-1 uppercase tracking-wider border border-cyan-700/50 bg-cyan-900/20 text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-30"
>
{voteAction.state === 'submitting' ? 'Submitting…' : 'Cast Vote'}
</button>
</div>
{voteAction.result && !voteAction.result.ok && (
<div className="text-red-400 font-mono mt-2 break-all">
<AlertCircle size={10} className="inline mr-1" />
{voteAction.result.reason}
</div>
)}
</div>
<div className="text-xs uppercase tracking-wider text-gray-500 mb-2">All Submitted Votes</div>
<div className="space-y-1 max-h-64 overflow-y-auto">
{market.votes.map((v) => (
<div key={v.node_id} className="flex items-center justify-between gap-2 text-xs border-b border-gray-800/30 py-1">
<span className="font-mono text-gray-400 truncate flex-1">{v.node_id.slice(0, 16)}</span>
<span className={v.side === 'yes' ? 'text-green-400' : 'text-red-400'}>{v.side?.toUpperCase()}</span>
<span className="w-20 text-right">
{v.eligible ? (
<CheckCircle2 size={12} className="text-green-400 inline" />
) : (
<span className="text-amber-400 flex items-center justify-end gap-1">
<XCircle size={12} />
<span className="text-xs">{v.ineligible_reason}</span>
</span>
)}
</span>
</div>
))}
</div>
</div>
)}
{!marketId && (
<div className="border border-gray-800 bg-black/40 p-6 text-center text-xs text-gray-500">
Open a bootstrap-indexed market from the Markets view to see its
eligible-node-one-vote tally here.
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,164 @@
'use client';
import React, { useEffect, useState } from 'react';
import { ChevronLeft, KeyRound, ShieldCheck, AlertTriangle, FileKey } from 'lucide-react';
import { fetchInfonetStatus, type InfonetStatus } from '@/mesh/infonetEconomyClient';
interface FunctionKeyViewProps {
onBack: () => void;
}
const PIECE_STATUS: Record<string, { color: string; label: string }> = {
not_implemented: { color: 'text-gray-500', label: 'NOT IMPLEMENTED' },
scaffolding: { color: 'text-amber-400', label: 'SCAFFOLDING' },
reference_impl: { color: 'text-blue-400', label: 'REFERENCE' },
production_rust: { color: 'text-green-400', label: 'PRODUCTION' },
};
export default function FunctionKeyView({ onBack }: FunctionKeyViewProps) {
const [status, setStatus] = useState<InfonetStatus | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const s = await fetchInfonetStatus();
if (!cancelled) setStatus(s);
} catch {
// ignore — render the design overview without status
}
})();
return () => { cancelled = true; };
}, []);
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
<button onClick={onBack} className="flex items-center text-cyan-400 hover:text-cyan-300 text-sm">
<ChevronLeft size={14} className="mr-1" /> BACK
</button>
<div className="text-sm text-purple-400 font-bold uppercase tracking-widest flex items-center gap-2">
<KeyRound size={16} /> FUNCTION KEYS Anonymous Citizenship Proof
</div>
<div />
</div>
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
<div className="text-xs text-gray-400 leading-relaxed">
A citizen proves &quot;I am an Infonet citizen&quot; to a real-world
operator <span className="text-purple-400">without revealing their Infonet identity</span>.
The naive approach (scramble a public key, record each redemption on chain) leaks
identity through metadata correlation. The Function Keys design is six pieces;
five are implemented; one (issuance via blind signatures / anonymous credentials)
waits on a cryptographic primitive decision.
</div>
{status && (
<div className="border border-gray-800 bg-black/40 p-3">
<div className="text-xs uppercase tracking-wider text-purple-400 mb-2 flex items-center gap-1">
<ShieldCheck size={12} /> Privacy Primitive Status
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
{Object.entries(status.privacy_primitive_status).map(([k, v]) => {
const style = PIECE_STATUS[v] ?? PIECE_STATUS.not_implemented;
return (
<div key={k}>
<div className="text-gray-500 capitalize">{k.replace(/_/g, ' ')}</div>
<div className={`${style.color} font-bold`}>{style.label}</div>
</div>
);
})}
</div>
<div className="text-xs text-gray-500 mt-2">
Cryptographic primitives are stubbed via the locked Protocol contracts in
<span className="font-mono"> services/infonet/privacy/contracts.py</span>.
When the privacy-core Rust binding lands, the scaffolding swaps for the
production class no caller changes.
</div>
</div>
)}
<div className="border border-gray-800 bg-black/40 p-3">
<div className="text-xs uppercase tracking-wider text-purple-400 mb-2">
The Six Pieces
</div>
<ol className="text-xs space-y-2 text-gray-300">
<li>
<span className="text-amber-400 font-bold">1. Issuance</span>{' '}
<span className="text-gray-500">(NOT IMPLEMENTED blind sig / BBS+ / U-Prove / Idemix)</span>
<div className="text-gray-400 ml-4">
Protocol issues a credential proving citizenship without linking to node_id.
</div>
</li>
<li>
<span className="text-green-400 font-bold">2. Nullifiers</span>{' '}
<span className="text-gray-500">(implemented pure SHA-256)</span>
<div className="text-gray-400 ml-4">
<span className="font-mono">nullifier = H(secret || operator_id)</span>.
Different operators see different nullifiers for the same key no
cross-operator linkage. One-time-use per (key, operator) pair via a
tracker.
</div>
</li>
<li>
<span className="text-green-400 font-bold">3. Challenge-Response</span>{' '}
<span className="text-gray-500">(implemented HMAC-SHA256 placeholder)</span>
<div className="text-gray-400 ml-4">
Operator issues a fresh nonce; key-holder signs with the Function Key&apos;s
secret. Defeats screenshot, replay, key-sharing. Production wires the chosen
blind-sig scheme; API stays compatible.
</div>
</li>
<li>
<span className="text-green-400 font-bold">4. Two-Phase Commit Receipts</span>{' '}
<span className="text-gray-500">(implemented)</span>
<div className="text-gray-400 ml-4">
Phase 1: operator signs a verification receipt (day-bucket date,
nullifier prefix only NO timestamps, NO full nullifiers, NO node_id).
Phase 2: citizen counter-signs after service rendered. Both parties hold
a copy. <span className="text-purple-400">Receipts NEVER published on-chain.</span>
</div>
</li>
<li>
<span className="text-green-400 font-bold">5. Enumerated Denial Codes</span>{' '}
<span className="text-gray-500">(implemented 3-value enum)</span>
<div className="text-gray-400 ml-4">
Operators can reject for exactly three reasons: invalid signature,
nullifier already seen, rate limit exceeded. Adding a 4th code is a hard
fork. Anti-discrimination by design.
</div>
</li>
<li>
<span className="text-green-400 font-bold">6. Batched Settlement</span>{' '}
<span className="text-gray-500">(implemented)</span>
<div className="text-gray-400 ml-4">
Operators settle in aggregate. Chain sees{' '}
<span className="font-mono">&#123;operator_id, period_id, count&#125;</span>{' '}
never per-receipt detail. Fraud detection via statistical auditing,
not per-redemption traces.
</div>
</li>
</ol>
</div>
<div className="border border-amber-900/50 bg-amber-900/10 p-3">
<div className="flex items-center gap-2 text-amber-400 text-xs font-bold uppercase tracking-wider mb-1">
<AlertTriangle size={12} /> Production Readiness
</div>
<div className="text-xs text-gray-400 space-y-1">
<div>
<FileKey size={11} className="inline mr-1" />
The HMAC-SHA256 placeholder requires the verifier to know the citizen&apos;s
secret that is NOT private. Production replaces it with a blind-sig
scheme that verifies without learning the secret.
</div>
<div>
The cryptographic scheme decision (RSA blind sigs vs BBS+ vs U-Prove vs
Idemix) is open per IMPLEMENTATION_PLAN §6.4.
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,373 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { ChevronLeft, AlertTriangle, Lock, Clock, ShieldOff, Loader, CheckCircle2 } from 'lucide-react';
import {
buildGateShutdownAppealFilePayload,
buildGateShutdownFilePayload,
buildGateSuspendFilePayload,
fetchGateState,
freshLocalId,
type GateState,
} from '@/mesh/infonetEconomyClient';
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
interface GateShutdownViewProps {
gateId: string;
onBack: () => void;
}
const STATUS_STYLE: Record<string, { color: string; label: string; icon: typeof Lock }> = {
active: { color: 'text-green-400', label: 'ACTIVE', icon: CheckCircle2 },
suspended: { color: 'text-amber-400', label: 'SUSPENDED', icon: Clock },
shutdown: { color: 'text-red-500', label: 'SHUTDOWN', icon: ShieldOff },
};
function formatTs(ts: number | null): string {
if (!ts) return '—';
return new Date(ts * 1000).toLocaleString();
}
function formatRelative(ts: number | null, now: number): string {
if (!ts) return '—';
const delta = ts - now;
const abs = Math.abs(delta);
const days = Math.floor(abs / 86400);
const hours = Math.floor((abs % 86400) / 3600);
if (delta > 0) {
if (days > 0) return `in ${days}d ${hours}h`;
return `in ${hours}h`;
}
if (days > 0) return `${days}d ago`;
return `${hours}h ago`;
}
export default function GateShutdownView({ gateId, onBack }: GateShutdownViewProps) {
const [data, setData] = useState<GateState | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filing forms — reused for suspend / shutdown / appeal.
const [reason, setReason] = useState('');
const [evidenceHash, setEvidenceHash] = useState('');
const suspendAction = useSignAndAppend();
const shutdownAction = useSignAndAppend();
const appealAction = useSignAndAppend();
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetchGateState(gateId);
if (res.ok) {
setData(res);
} else {
setError(res.reason);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'network error');
} finally {
setLoading(false);
}
}, [gateId]);
const hasActivePhase = data?.suspension.status === 'suspended';
useEffect(() => {
void reload();
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 30_000);
return () => clearInterval(interval);
}, [reload, hasActivePhase]);
const status = data ? STATUS_STYLE[data.suspension.status] ?? STATUS_STYLE.active : null;
const fileSuspend = useCallback(async () => {
if (!reason.trim() || !evidenceHash.trim()) return;
const built = buildGateSuspendFilePayload(
freshLocalId('sus'), gateId, reason.trim(), [evidenceHash.trim()],
);
const res = await suspendAction.submit(built.event_type, built.payload);
if (res.ok) {
setReason(''); setEvidenceHash('');
void reload();
}
}, [reason, evidenceHash, gateId, suspendAction, reload]);
const fileShutdown = useCallback(async () => {
if (!reason.trim() || !evidenceHash.trim()) return;
const built = buildGateShutdownFilePayload(
freshLocalId('shd'), gateId, reason.trim(), [evidenceHash.trim()],
);
const res = await shutdownAction.submit(built.event_type, built.payload);
if (res.ok) {
setReason(''); setEvidenceHash('');
void reload();
}
}, [reason, evidenceHash, gateId, shutdownAction, reload]);
const fileAppeal = useCallback(async () => {
if (!reason.trim() || !evidenceHash.trim()) return;
if (!data?.shutdown.pending_petition_id) return;
const built = buildGateShutdownAppealFilePayload(
freshLocalId('app'),
gateId,
data.shutdown.pending_petition_id,
reason.trim(),
[evidenceHash.trim()],
);
const res = await appealAction.submit(built.event_type, built.payload);
if (res.ok) {
setReason(''); setEvidenceHash('');
void reload();
}
}, [reason, evidenceHash, gateId, data, appealAction, reload]);
const canFileSuspend = data?.suspension.status === 'active';
const canFileShutdown = data?.suspension.status === 'suspended' && !data?.shutdown.has_pending;
const canFileAppeal = data?.shutdown.pending_status === 'executing';
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
<button onClick={onBack} className="flex items-center text-cyan-400 hover:text-cyan-300 text-sm">
<ChevronLeft size={14} className="mr-1" /> BACK
</button>
<div className="text-sm text-amber-400 font-bold uppercase tracking-widest flex items-center gap-2">
<ShieldOff size={16} /> GATE SHUTDOWN {gateId}
</div>
<button
onClick={() => void reload()}
disabled={loading}
className="text-xs text-gray-500 hover:text-amber-400 disabled:opacity-30"
>
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
</button>
</div>
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
<div className="text-xs text-gray-500 leading-relaxed">
Gate shutdown is two-tier: <span className="text-amber-400">SUSPEND</span> (30-day reversible freeze)
<span className="text-red-400">SHUTDOWN</span> (irreversible archive, 7-day execution delay
with one typed appeal allowed). Voting uses oracle_rep_active weight; thresholds are higher for
locked gates (<span className="text-cyan-400">75% suspend / 80% shutdown</span> instead of 67% / 75%).
Anti-stall: one appeal per shutdown, 48h filing window after vote passes.
</div>
{error && (
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
<AlertTriangle size={12} className="inline mr-1" /> {error}
</div>
)}
{data && status && (
<>
<div className="border border-gray-800 bg-black/40 p-3">
<div className="flex items-center gap-2 mb-2">
<status.icon size={14} className={status.color} />
<span className={`text-xs font-bold uppercase tracking-wider ${status.color}`}>
{status.label}
</span>
{data.locked.is_locked && (
<span className="ml-2 text-cyan-400 text-xs flex items-center gap-1">
<Lock size={12} /> LOCKED
</span>
)}
{data.ratified && (
<span className="ml-2 text-green-400 text-xs"> RATIFIED</span>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
<div>
<div className="text-gray-500">Members</div>
<div className="text-white">{data.members.length}</div>
</div>
<div>
<div className="text-gray-500">Cumulative Oracle Rep</div>
<div className="text-white">{data.cumulative_member_oracle_rep.toFixed(2)}</div>
</div>
<div>
<div className="text-gray-500">Entry Sacrifice</div>
<div className="text-white">{data.meta.entry_sacrifice} common rep</div>
</div>
<div>
<div className="text-gray-500">Min Overall Rep</div>
<div className="text-white">{data.meta.min_overall_rep}</div>
</div>
<div>
<div className="text-gray-500">Created</div>
<div className="text-white text-xs">{formatTs(data.meta.created_at)}</div>
</div>
<div>
<div className="text-gray-500">Locked At</div>
<div className="text-white text-xs">
{data.locked.locked_at ? formatTs(data.locked.locked_at) : '—'}
</div>
</div>
</div>
</div>
{data.suspension.status === 'suspended' && (
<div className="border border-amber-900/50 bg-amber-900/10 p-3">
<div className="text-xs uppercase tracking-wider text-amber-400 mb-2 flex items-center gap-1">
<Clock size={12} /> Suspension State
</div>
<div className="text-xs text-gray-300 space-y-1">
<div>Suspended at: <span className="text-white">{formatTs(data.suspension.suspended_at)}</span></div>
<div>
Auto-unsuspends:{' '}
<span className="text-amber-400">
{formatRelative(data.suspension.suspended_until, data.now)} ({formatTs(data.suspension.suspended_until)})
</span>
</div>
<div className="text-gray-500 mt-2">
During suspension: no gate_message, gate_enter, gate_exit. Members retain
membership; content preserved (append-only).
</div>
</div>
</div>
)}
{data.shutdown.has_pending && (
<div className="border border-red-900/50 bg-red-900/10 p-3">
<div className="text-xs uppercase tracking-wider text-red-400 mb-2 flex items-center gap-1">
<ShieldOff size={12} /> Pending Shutdown Petition
</div>
<div className="text-xs space-y-1">
<div className="text-gray-300">
ID: <span className="font-mono text-white">{data.shutdown.pending_petition_id}</span>
</div>
<div className="text-gray-300">
Status:{' '}
<span className={
data.shutdown.pending_status === 'executing' ? 'text-red-400' :
data.shutdown.pending_status === 'appealed' ? 'text-amber-400' :
data.shutdown.pending_status === 'voided_appeal' ? 'text-green-400' :
'text-gray-300'
}>
{data.shutdown.pending_status?.toUpperCase()}
</span>
</div>
{data.shutdown.execution_at && (
<div className="text-red-400">
Executes {formatRelative(data.shutdown.execution_at, data.now)} (
{formatTs(data.shutdown.execution_at)})
</div>
)}
{data.shutdown.pending_status === 'appealed' && (
<div className="text-amber-400">
Execution timer is PAUSED while appeal is voted on. If the appeal
passes, the shutdown is voided. If it fails, the timer resumes.
</div>
)}
</div>
</div>
)}
{data.shutdown.executed && (
<div className="border border-red-900/50 bg-red-900/20 p-3 text-xs">
<div className="text-red-400 font-bold uppercase tracking-wider mb-1">
GATE SHUT DOWN IRREVERSIBLE
</div>
<div className="text-gray-400">
Members released. Content archived. gate_id retired. No petition can reopen.
</div>
</div>
)}
{!data.shutdown.executed && (canFileSuspend || canFileShutdown || canFileAppeal) && (
<div className="border border-gray-800 bg-black/40 p-3">
<div className="text-xs uppercase tracking-wider text-gray-300 mb-2">
File a Petition
</div>
<div className="text-xs text-gray-500 mb-2">
Reason and at least one evidence hash are required. Filing
costs common rep (suspend: 15, shutdown: 25, appeal: 20)
and triggers a 7-day vote window.
{canFileSuspend && ' Suspend → 30-day reversible freeze.'}
{canFileShutdown && ' Shutdown requires active suspension.'}
{canFileAppeal && ' Appeal pauses the 7-day execution timer.'}
</div>
<div className="space-y-2 text-xs">
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="reason (max 2000 chars)"
maxLength={2000}
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
/>
<input
type="text"
value={evidenceHash}
onChange={(e) => setEvidenceHash(e.target.value)}
placeholder="evidence hash (e.g. ipfs://… or sha256:…)"
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
/>
<div className="flex flex-wrap gap-2">
{canFileSuspend && (
<button
type="button"
onClick={fileSuspend}
disabled={
suspendAction.state === 'submitting' ||
!reason.trim() || !evidenceHash.trim()
}
className="px-3 py-1 uppercase tracking-wider border border-amber-700/50 bg-amber-900/20 text-amber-400 hover:bg-amber-900/40 disabled:opacity-30"
>
{suspendAction.state === 'submitting' ? 'Filing…' : 'File Suspend'}
</button>
)}
{canFileShutdown && (
<button
type="button"
onClick={fileShutdown}
disabled={
shutdownAction.state === 'submitting' ||
!reason.trim() || !evidenceHash.trim()
}
className="px-3 py-1 uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
>
{shutdownAction.state === 'submitting' ? 'Filing…' : 'File Shutdown'}
</button>
)}
{canFileAppeal && (
<button
type="button"
onClick={fileAppeal}
disabled={
appealAction.state === 'submitting' ||
!reason.trim() || !evidenceHash.trim()
}
className="px-3 py-1 uppercase tracking-wider border border-cyan-700/50 bg-cyan-900/20 text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-30"
>
{appealAction.state === 'submitting' ? 'Filing…' : 'File Appeal'}
</button>
)}
</div>
</div>
{(suspendAction.result && !suspendAction.result.ok) && (
<div className="text-red-400 font-mono text-xs mt-2 break-all">
<AlertTriangle size={10} className="inline mr-1" />
{suspendAction.result.reason}
</div>
)}
{(shutdownAction.result && !shutdownAction.result.ok) && (
<div className="text-red-400 font-mono text-xs mt-2 break-all">
<AlertTriangle size={10} className="inline mr-1" />
{shutdownAction.result.reason}
</div>
)}
{(appealAction.result && !appealAction.result.ok) && (
<div className="text-red-400 font-mono text-xs mt-2 break-all">
<AlertTriangle size={10} className="inline mr-1" />
{appealAction.result.reason}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
);
}
@@ -3,17 +3,36 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ArrowDown, ArrowUp, ChevronLeft, RefreshCw, Reply, Search, Send } from 'lucide-react';
import { API_BASE } from '@/lib/api';
import { controlPlaneJson } from '@/lib/controlPlane';
import {
nextGateMessagesPollDelayMs,
nextGateMessagesWaitRearmDelayMs,
nextGateMessagesWaitTimeoutMs,
} from '@/mesh/gateMetadataTiming';
import {
ACTIVE_GATE_ROOM_MESSAGE_LIMIT,
fetchGateMessageSnapshotState,
waitForGateMessageSnapshot,
} from '@/mesh/gateMessageSnapshot';
import {
getGateSessionStreamStatus,
retainGateSessionStreamGate,
subscribeGateSessionStreamEvents,
subscribeGateSessionStreamStatus,
} from '@/mesh/gateSessionStream';
import { nextSequence } from '@/mesh/meshIdentity';
import {
approveGateCompatFallback,
decryptWormholeGateMessages,
fetchWormholeGateKeyStatus,
hasGateCompatFallbackApproval,
postWormholeGateMessage,
prepareWormholeInteractiveLane,
signMeshEvent,
syncBrowserWormholeGateState,
type WormholeGateKeyStatus,
} from '@/mesh/wormholeIdentityClient';
import { gateEnvelopeDisplayText, gateEnvelopeState, isEncryptedGateEnvelope } from '@/mesh/gateEnvelope';
import { validateEventPayload } from '@/mesh/meshSchema';
import { useGateSSE } from '@/hooks/useGateSSE';
const GATE_INTROS: Record<string, string> = {
infonet:
@@ -52,6 +71,8 @@ interface GateViewProps {
onNavigateGate: (gate: string) => void;
onOpenLiveGate?: (gate: string) => void;
availableGates: string[];
/** Open the gate shutdown lifecycle view. */
onOpenShutdownPetition?: (gate: string) => void;
}
interface GateMessage {
@@ -65,6 +86,7 @@ interface GateMessage {
sender_ref?: string;
format?: string;
gate_envelope?: string;
envelope_hash?: string;
decrypted_message?: string;
payload?: {
gate?: string;
@@ -73,6 +95,7 @@ interface GateMessage {
sender_ref?: string;
format?: string;
gate_envelope?: string;
envelope_hash?: string;
reply_to?: string;
};
gate?: string;
@@ -93,9 +116,6 @@ interface ReplyContext {
nodeId: string;
}
const GATE_ACCESS_PROOF_TTL_MS = 45_000;
const gateAccessHeaderCache = new Map<string, { headers: Record<string, string>; expiresAt: number }>();
function timeAgo(timestamp: number): string {
const ts = Number(timestamp || 0);
if (!ts) return 'just now';
@@ -176,44 +196,84 @@ function normalizeGateMessage(message: GateMessage): GateMessage {
sender_ref: String(message.sender_ref ?? payload?.sender_ref ?? ''),
format: String(message.format ?? payload?.format ?? ''),
gate_envelope: String(message.gate_envelope ?? payload?.gate_envelope ?? ''),
envelope_hash: String(message.envelope_hash ?? payload?.envelope_hash ?? ''),
reply_to: String(message.reply_to ?? payload?.reply_to ?? ''),
};
}
async function buildGateAccessHeaders(gateId: string): Promise<Record<string, string> | undefined> {
function describeGateCompatError(detail: string, gateId: string = ''): string {
const normalized = String(detail || '').trim();
const lowered = normalized.toLowerCase();
if (
lowered.includes('transport tier insufficient') ||
lowered.includes('warming up in the background')
) {
return 'The obfuscated lane is still warming up in the background. Stay in the room and posting should unlock shortly.';
}
if (normalized === 'gate_compat_fallback_consent_required') {
return 'Local gate runtime is unavailable for this room.';
}
if (normalized.startsWith('gate_local_runtime_required:')) {
const reason = normalized.slice('gate_local_runtime_required:'.length);
return `${describeGateCompatReason(reason, gateId)} Use native desktop or resync local gate state.`;
}
if (normalized === 'gate_backend_plaintext_compat_required') {
return 'Service-side gate send is disabled on this runtime. Use native desktop or an explicit compatibility override.';
}
if (normalized === 'gate_envelope_required') {
return 'Local gate sealing is warming up. Your draft is still here.';
}
if (normalized === 'gate_envelope_encrypt_failed') {
return 'Local gate sealing could not finish. Your draft is still here.';
}
return normalized;
}
function describeGateCompatConsentPrompt(action: string): string {
switch (String(action || '')) {
case 'decrypt':
return 'Use compatibility mode for this room to read messages on this device.';
case 'compose':
case 'post':
return 'Use compatibility mode for this room to send messages on this device.';
default:
return 'Use compatibility mode for this room on this device.';
}
}
function describeGateCompatReason(reason: string, gateId: string): string {
const normalizedGate = String(gateId || '').trim().toLowerCase();
if (!normalizedGate) return undefined;
const cached = gateAccessHeaderCache.get(normalizedGate);
if (cached && cached.expiresAt > Date.now()) {
return cached.headers;
const detail = String(reason || '').trim().toLowerCase();
if (!detail || detail === 'browser_local_gate_crypto_unavailable') {
return 'Local gate crypto failed on this device.';
}
try {
const proof = await controlPlaneJson<{ node_id?: string; ts?: number; proof?: string }>(
'/api/wormhole/gate/proof',
{
requireAdminSession: false,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gate_id: normalizedGate }),
},
);
const nodeId = String(proof.node_id || '').trim();
const gateProof = String(proof.proof || '').trim();
const gateTs = String(proof.ts || '').trim();
if (!nodeId || !gateProof || !gateTs) return undefined;
const headers = {
'X-Wormhole-Node-Id': nodeId,
'X-Wormhole-Gate-Proof': gateProof,
'X-Wormhole-Gate-Ts': gateTs,
};
gateAccessHeaderCache.set(normalizedGate, {
headers,
expiresAt: Date.now() + GATE_ACCESS_PROOF_TTL_MS,
});
return headers;
} catch {
return undefined;
if (detail === 'browser_gate_worker_unavailable') {
return 'This runtime cannot use the local gate worker.';
}
if (detail.startsWith('browser_gate_state_resync_required:')) {
return normalizedGate
? `Local ${normalizedGate} state needs a resync on this device.`
: 'Local gate state needs a resync on this device.';
}
if (
detail.startsWith('browser_gate_state_mapping_missing_group:') ||
detail === 'browser_gate_state_active_member_missing'
) {
return 'Local gate state is incomplete on this device.';
}
if (detail === 'worker_gate_wrap_key_missing') {
return 'Secure local gate storage is unavailable in this browser.';
}
if (detail === 'gate_mls_decrypt_failed') {
return 'Local gate decrypt failed on this device.';
}
return 'Local gate crypto failed on this device.';
}
interface GateCompatConsentPromptState {
gateId: string;
action: 'compose' | 'post' | 'decrypt';
reason: string;
}
export default function GateView({
@@ -224,9 +284,17 @@ export default function GateView({
onNavigateGate,
onOpenLiveGate: _onOpenLiveGate,
availableGates,
onOpenShutdownPetition,
}: GateViewProps) {
const [searchInput, setSearchInput] = useState('');
const [messages, setMessages] = useState<GateMessage[]>([]);
// Self-authored plaintext, keyed by real event_id returned from the POST.
// This lives in React state ONLY — pure RAM, dies with the tab, never
// written to disk or sessionStorage. It exists so a refresh that replaces
// the messages array with ciphertext from the server doesn't wipe the
// author's view of what they just said. MLS's forward-secrecy property
// (sender can't re-decrypt own output) is preserved on the wire / on disk.
const [selfAuthoredByEventId, setSelfAuthoredByEventId] = useState<Record<string, string>>({});
const [composer, setComposer] = useState('');
const [busy, setBusy] = useState(false);
const [roomError, setRoomError] = useState('');
@@ -236,45 +304,116 @@ export default function GateView({
const [reps, setReps] = useState<Record<string, number>>({});
const [voteNotice, setVoteNotice] = useState('');
const [votedOn, setVotedOn] = useState<Record<string, 1 | -1>>({});
const [compatActive, setCompatActive] = useState(false);
const [compatConsentPrompt, setCompatConsentPrompt] = useState<GateCompatConsentPromptState | null>(null);
const [streamStatus, setStreamStatus] = useState(() => getGateSessionStreamStatus());
const [streamStatusHydrated, setStreamStatusHydrated] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pollTimerRef = useRef<number | null>(null);
const waitAbortRef = useRef<AbortController | null>(null);
const gateCursorRef = useRef(0);
const repsRef = useRef<Record<string, number>>({});
const streamEnabledForGateRef = useRef(false);
const gateId = useMemo(() => String(gateName || '').trim().toLowerCase(), [gateName]);
const introMessage =
GATE_INTROS[gateId] || 'Welcome to this gate. Be civil. The Shadowbroker is watching.';
useEffect(() => {
setCompatActive(hasGateCompatFallbackApproval(gateId));
setCompatConsentPrompt(null);
gateCursorRef.current = 0;
}, [gateId]);
useEffect(
() =>
subscribeGateSessionStreamStatus((nextStatus) => {
setStreamStatus(nextStatus);
setStreamStatusHydrated(true);
}),
[],
);
useEffect(() => {
if (!gateId || !status?.has_local_access) {
return;
}
return retainGateSessionStreamGate(gateId);
}, [gateId, status?.has_local_access]);
useEffect(() => {
if (!gateId || !status?.has_local_access) {
return;
}
void prepareWormholeInteractiveLane({
minimumTransportTier: 'private_control_only',
}).catch(() => undefined);
}, [gateId, status?.has_local_access]);
const streamEnabledForGate =
Boolean(gateId) &&
streamStatus.phase === 'open' &&
streamStatus.subscriptions.includes(gateId);
const streamPreferredForGate =
Boolean(gateId) &&
(streamStatus.phase === 'connecting' || streamStatus.phase === 'open') &&
streamStatus.subscriptions.includes(gateId);
useEffect(() => {
streamEnabledForGateRef.current = streamPreferredForGate;
}, [streamPreferredForGate]);
const searchMatch = searchInput.startsWith('g/')
? availableGates.find((g) => g.startsWith(searchInput.slice(2).toLowerCase()))
: null;
const voteScopeKey = useCallback((targetId: string) => `${gateId}::${String(targetId || '').trim()}`, [gateId]);
const hydrateMessages = useCallback(async (rawMessages: GateMessage[]): Promise<GateMessage[]> => {
const hydrateMessages = useCallback(async (
rawMessages: GateMessage[],
): Promise<{ messages: GateMessage[]; compatDecryptBlocked: boolean; roomError?: string }> => {
const baseMessages = (Array.isArray(rawMessages) ? rawMessages : []).map(normalizeGateMessage);
const encrypted = baseMessages
.map((message, index) => ({ message, index }))
.filter(({ message }) => isEncryptedGateEnvelope(message));
if (!encrypted.length) {
return baseMessages.map((message) => ({ ...message, decrypted_message: '' }));
return {
messages: baseMessages.map((message) => ({ ...message, decrypted_message: '' })),
compatDecryptBlocked: false,
roomError: '',
};
}
try {
const batch = await decryptWormholeGateMessages(
encrypted.map(({ message }) => ({
gate_id: String(message.gate || gateId),
epoch: Number(message.epoch || 0),
ciphertext: String(message.ciphertext || ''),
nonce: String(message.nonce || ''),
sender_ref: String(message.sender_ref || ''),
format: String(message.format || 'mls1'),
gate_envelope: String(message.gate_envelope || ''),
})),
encrypted.map(({ message }) => {
const gateEnvelope = String(message.gate_envelope || '');
return {
gate_id: String(message.gate || gateId),
epoch: Number(message.epoch || 0),
ciphertext: String(message.ciphertext || ''),
nonce: String(message.nonce || ''),
sender_ref: String(message.sender_ref || ''),
format: String(message.format || 'mls1'),
gate_envelope: gateEnvelope,
envelope_hash: String(message.envelope_hash || ''),
// If a gate_envelope is present, go straight to the backend
// envelope-fast-path by signaling recovery_envelope=true.
// This skips browser-side MLS (which has empty state across
// fresh anon sessions) and uses the durable AES-GCM envelope
// keyed under gate_secret — which EVERY gate member can
// decrypt as long as they hold the current gate_secret.
recovery_envelope: gateEnvelope.length > 0,
};
}),
);
const results = Array.isArray(batch.results) ? batch.results : [];
const nextMessages = [...baseMessages];
encrypted.forEach(({ index, message }, resultIndex) => {
const decrypted = results[resultIndex];
const decryptedReplyTo = decrypted?.ok ? String(decrypted.reply_to || '').trim() : '';
nextMessages[index] = {
...message,
decrypted_message: decrypted?.ok
@@ -285,43 +424,45 @@ export default function GateView({
: String(decrypted.plaintext || ''))
: '',
epoch: decrypted?.ok ? Number(decrypted.epoch || message.epoch || 0) : message.epoch,
reply_to: decryptedReplyTo || String(message.reply_to || ''),
};
});
return nextMessages;
} catch {
return baseMessages.map((message) => ({ ...message, decrypted_message: '' }));
return {
messages: nextMessages,
compatDecryptBlocked: false,
roomError: '',
};
} catch (error) {
const detail = error instanceof Error ? error.message : '';
if (
detail === 'gate_compat_fallback_consent_required' ||
detail.startsWith('gate_local_runtime_required:')
) {
return {
messages: baseMessages.map((message) => ({ ...message, decrypted_message: '' })),
compatDecryptBlocked: false,
roomError: describeGateCompatError(detail, gateId),
};
}
return {
messages: baseMessages.map((message) => ({ ...message, decrypted_message: '' })),
compatDecryptBlocked: false,
roomError: '',
};
}
}, [gateId]);
const refreshGate = useCallback(async () => {
if (!gateId) return;
setLoading(true);
try {
const nextStatus = await fetchWormholeGateKeyStatus(gateId);
setStatus(nextStatus);
if (!nextStatus?.ok || !nextStatus.has_local_access) {
setMessages([]);
setRoomError(String(nextStatus?.detail || 'Gate access still syncing'));
return;
}
const headers = await buildGateAccessHeaders(gateId);
if (!headers) {
setMessages([]);
setRoomError('Gate proof unavailable');
return;
}
const params = new URLSearchParams({ limit: '40', gate: gateId });
const res = await fetch(`${API_BASE}/api/mesh/infonet/messages?${params}`, { headers });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setMessages([]);
setRoomError(String(data?.detail || 'Failed to load gate room'));
return;
}
const hydrated = await hydrateMessages(Array.isArray(data.messages) ? data.messages : []);
const chronological = [...hydrated].reverse();
const applyGateMessages = useCallback(
async (rawMessages: GateMessage[]) => {
const normalizedMessages = Array.isArray(rawMessages) ? rawMessages : [];
const hydrated = await hydrateMessages(normalizedMessages);
const chronological = [...hydrated.messages].reverse();
setMessages(chronological);
setRoomError('');
if (hydrated.roomError) {
setRoomError(hydrated.roomError);
} else if (!hydrated.compatDecryptBlocked) {
setRoomError('');
}
const uniqueEventIds = Array.from(
new Set(
@@ -332,14 +473,20 @@ export default function GateView({
);
if (uniqueEventIds.length > 0) {
try {
const params = new URLSearchParams();
for (const eid of uniqueEventIds) params.append('node_id', eid);
const repRes = await fetch(`${API_BASE}/api/mesh/reputation/batch?${params}`);
if (repRes.ok) {
const repData = await repRes.json();
const freshReps: Record<string, number> = {};
if (repData.reputations && typeof repData.reputations === 'object') {
for (const [k, v] of Object.entries(repData.reputations)) {
const uncachedEventIds = uniqueEventIds.filter(
(eventId) => !Object.prototype.hasOwnProperty.call(repsRef.current, eventId),
);
if (uncachedEventIds.length === 0) {
return;
}
const params = new URLSearchParams();
for (const eid of uncachedEventIds) params.append('node_id', eid);
const repRes = await fetch(`${API_BASE}/api/mesh/reputation/batch?${params}`);
if (repRes.ok) {
const repData = await repRes.json();
const freshReps: Record<string, number> = {};
if (repData.reputations && typeof repData.reputations === 'object') {
for (const [k, v] of Object.entries(repData.reputations)) {
freshReps[k] = Number(v || 0);
}
}
@@ -351,32 +498,246 @@ export default function GateView({
/* ignore batch rep fetch failure */
}
}
},
[hydrateMessages],
);
const refreshGate = useCallback(async (options: { force?: boolean } = {}): Promise<boolean> => {
if (!gateId) return false;
setLoading(true);
try {
const streamOwned = streamEnabledForGateRef.current;
const nextStatus = await fetchWormholeGateKeyStatus(gateId, {
force: options.force,
mode: streamOwned ? 'session_stream' : 'active_room',
});
setStatus(nextStatus);
if (!nextStatus?.ok || !nextStatus.has_local_access) {
gateCursorRef.current = 0;
setMessages([]);
setRoomError(String(nextStatus?.detail || 'Gate access still syncing'));
return false;
}
if (options.force || !streamOwned || !status?.has_local_access) {
await syncBrowserWormholeGateState(gateId).catch(() => false);
}
const snapshot = await fetchGateMessageSnapshotState(gateId, ACTIVE_GATE_ROOM_MESSAGE_LIMIT, {
force: options.force,
proofMode: streamOwned ? 'session_stream' : 'default',
});
gateCursorRef.current = snapshot.cursor;
await applyGateMessages(snapshot.messages as GateMessage[]);
return true;
} catch (error) {
setRoomError(error instanceof Error ? error.message : 'Failed to load gate room');
return false;
} finally {
setLoading(false);
}
}, [gateId, hydrateMessages]);
}, [applyGateMessages, gateId, status?.has_local_access]);
// SSE: instant delivery when new gate events arrive
const handleSSEEvent = useCallback(
(eventGateId: string) => {
if (eventGateId === gateId) void refreshGate();
},
[gateId, refreshGate],
);
useGateSSE(handleSSEEvent);
// Fallback poll (30s) in case SSE disconnects
useEffect(() => {
void refreshGate();
const timer = window.setInterval(() => {
void refreshGate();
}, 30_000);
return () => {
window.clearInterval(timer);
if (!gateId || !status?.has_local_access || !streamEnabledForGate) {
return;
}
return subscribeGateSessionStreamEvents((event) => {
if (event.event !== 'gate_update' || !event.data || typeof event.data !== 'object') {
return;
}
const updates = Array.isArray((event.data as { updates?: unknown }).updates)
? ((event.data as { updates?: Array<{ gate_id?: string; cursor?: number }> }).updates || [])
: [];
const matching = updates.find(
(update) => String(update?.gate_id || '').trim().toLowerCase() === gateId,
);
if (!matching) {
return;
}
void (async () => {
try {
const snapshot = await fetchGateMessageSnapshotState(
gateId,
ACTIVE_GATE_ROOM_MESSAGE_LIMIT,
{ force: true, proofMode: 'session_stream' },
);
gateCursorRef.current = snapshot.cursor;
await applyGateMessages(snapshot.messages as GateMessage[]);
} catch {
await refreshGate({ force: true });
}
})();
});
}, [applyGateMessages, gateId, refreshGate, status?.has_local_access, streamEnabledForGate]);
// Active gate rooms now wait for server-side change instead of issuing a fresh fetch on every cycle.
useEffect(() => {
if (!streamStatusHydrated) {
return;
}
const isLiveStreamPreferredForGate = () => {
const liveStreamStatus = getGateSessionStreamStatus();
return (
Boolean(gateId) &&
(liveStreamStatus.phase === 'connecting' || liveStreamStatus.phase === 'open') &&
liveStreamStatus.subscriptions.includes(gateId)
);
};
}, [refreshGate]);
const liveStreamPreferred = streamPreferredForGate || isLiveStreamPreferredForGate();
streamEnabledForGateRef.current = liveStreamPreferred;
let cancelled = false;
const clearRetry = () => {
if (pollTimerRef.current) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
};
const scheduleRetry = () => {
if (cancelled || streamEnabledForGateRef.current) return;
clearRetry();
pollTimerRef.current = window.setTimeout(() => {
pollTimerRef.current = null;
void waitForNextChange();
}, nextGateMessagesPollDelayMs());
};
const startWaitIfNeeded = () => {
queueMicrotask(() => {
streamEnabledForGateRef.current =
streamPreferredForGate || isLiveStreamPreferredForGate();
if (!cancelled && !streamEnabledForGateRef.current) {
void waitForNextChange();
}
});
};
const waitForNextChange = async () => {
streamEnabledForGateRef.current =
streamPreferredForGate || isLiveStreamPreferredForGate();
if (cancelled || !gateId || streamEnabledForGateRef.current) return;
const controller = new AbortController();
waitAbortRef.current = controller;
try {
const snapshot = await waitForGateMessageSnapshot(
gateId,
gateCursorRef.current,
ACTIVE_GATE_ROOM_MESSAGE_LIMIT,
{
timeoutMs: nextGateMessagesWaitTimeoutMs(),
signal: controller.signal,
},
);
waitAbortRef.current = null;
if (cancelled) return;
gateCursorRef.current = snapshot.cursor;
if (snapshot.changed) {
await applyGateMessages(snapshot.messages as GateMessage[]);
void waitForNextChange();
return;
}
clearRetry();
pollTimerRef.current = window.setTimeout(() => {
pollTimerRef.current = null;
void waitForNextChange();
}, nextGateMessagesWaitRearmDelayMs());
} catch (error) {
waitAbortRef.current = null;
if (cancelled || controller.signal.aborted) {
return;
}
const ready = await refreshGate({ force: true });
if (!ready) {
setRoomError(error instanceof Error ? error.message : 'Failed to load gate room');
scheduleRetry();
return;
}
startWaitIfNeeded();
}
};
if (liveStreamPreferred) {
void refreshGate();
return () => {
cancelled = true;
clearRetry();
if (waitAbortRef.current) {
waitAbortRef.current.abort();
waitAbortRef.current = null;
}
};
}
void refreshGate().then((ready) => {
streamEnabledForGateRef.current =
streamPreferredForGate || isLiveStreamPreferredForGate();
if (!cancelled && ready && !streamEnabledForGateRef.current) {
startWaitIfNeeded();
}
});
return () => {
cancelled = true;
clearRetry();
if (waitAbortRef.current) {
waitAbortRef.current.abort();
waitAbortRef.current = null;
}
};
}, [applyGateMessages, gateId, refreshGate, streamPreferredForGate, streamStatusHydrated]);
useEffect(() => {
setCompatConsentPrompt(null);
}, [gateId]);
useEffect(() => {
repsRef.current = reps;
}, [reps]);
useEffect(() => {
const handleCompatFallback = (event: Event) => {
const detail =
event instanceof CustomEvent && event.detail && typeof event.detail === 'object'
? (event.detail as { gateId?: string; action?: string })
: {};
const eventGateId = String(detail.gateId || '').trim().toLowerCase();
if (!eventGateId || eventGateId !== gateId) {
return;
}
setCompatActive(true);
};
window.addEventListener('sb:gate-compat-fallback', handleCompatFallback as EventListener);
return () => {
window.removeEventListener('sb:gate-compat-fallback', handleCompatFallback as EventListener);
};
}, [gateId]);
useEffect(() => {
const handleCompatConsentRequired = (event: Event) => {
const detail =
event instanceof CustomEvent && event.detail && typeof event.detail === 'object'
? (event.detail as GateCompatConsentPromptState)
: null;
const eventGateId = String(detail?.gateId || '').trim().toLowerCase();
if (!eventGateId || eventGateId !== gateId || !detail) {
return;
}
setCompatConsentPrompt({
gateId: eventGateId,
action: detail.action,
reason: String(detail.reason || ''),
});
};
window.addEventListener(
'sb:gate-compat-consent-required',
handleCompatConsentRequired as EventListener,
);
return () => {
window.removeEventListener(
'sb:gate-compat-consent-required',
handleCompatConsentRequired as EventListener,
);
};
}, [gateId]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -405,27 +766,31 @@ export default function GateView({
setBusy(true);
setRoomError('');
try {
await controlPlaneJson<{ ok: boolean; detail?: string }>('/api/wormhole/gate/message/post', {
requireAdminSession: false,
capabilityIntent: 'wormhole_gate_content',
sessionProfileHint: 'gate_operator',
enforceProfileHint: true,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gate_id: gateId,
plaintext: msg,
reply_to: replyContext?.eventId || '',
}),
});
const gatePost = await postWormholeGateMessage(gateId, msg, replyContext?.eventId || '').catch((error) => ({
ok: false,
detail: error instanceof Error ? error.message : 'Gate post failed',
}));
if (gatePost?.ok === false) {
throw new Error(describeGateCompatError(String(gatePost.detail || 'Gate post failed'), gateId));
}
setComposer('');
setReplyContext(null);
// Optimistic: append a placeholder message so the user sees it immediately,
// then let the next poll cycle (8s) hydrate it with the real encrypted copy.
// Capture the server-assigned event_id and remember the plaintext we
// just authored, keyed by that event_id. The refresh will bring back
// the same event as ciphertext; during render we paint over its
// decrypted_message with what we typed. Pure React state — when the
// tab closes, this map vanishes.
const realEventId = String((gatePost as { event_id?: string })?.event_id || '');
if (realEventId) {
setSelfAuthoredByEventId((prev) => ({ ...prev, [realEventId]: msg }));
}
// Optimistic placeholder so the post appears instantly even before
// the next refresh round-trip completes. Uses the real event_id when
// available so the refresh merges cleanly rather than duplicating.
setMessages((prev) => [
...prev,
{
event_id: `_pending_${Date.now()}`,
event_id: realEventId || `_pending_${Date.now()}`,
message: msg,
decrypted_message: msg,
timestamp: Math.floor(Date.now() / 1000),
@@ -435,8 +800,6 @@ export default function GateView({
ephemeral: true,
} as GateMessage,
]);
// Non-blocking background refresh to pick up the real message
void refreshGate();
} catch (error) {
const errMsg = error instanceof Error ? error.message : 'Gate post failed';
// Suppress technical sequence/replay errors — just show a clean retry hint
@@ -448,7 +811,21 @@ export default function GateView({
} finally {
setBusy(false);
}
}, [busy, composer, gateId, persona, refreshGate, replyContext, status?.has_local_access]);
}, [busy, composer, gateId, persona, replyContext, status?.has_local_access]);
const approveCompatFallback = useCallback(() => {
if (!compatConsentPrompt?.gateId) return;
approveGateCompatFallback(compatConsentPrompt.gateId);
const action = compatConsentPrompt.action;
setCompatActive(true);
setCompatConsentPrompt(null);
setRoomError('');
if (action === 'decrypt') {
void refreshGate({ force: true });
return;
}
void handleSend();
}, [compatConsentPrompt, handleSend, refreshGate]);
const handleVote = useCallback(async (eventId: string, vote: 1 | -1) => {
if (!eventId || !gateId || votedOn[voteScopeKey(eventId)] === vote) return;
@@ -503,19 +880,45 @@ export default function GateView({
}
}, [gateId, voteScopeKey, votedOn]);
const threadedMessages = useMemo(() => buildThreadedList(messages), [messages]);
// Overlay self-authored plaintexts onto the refreshed message list.
// Lives only in this component's React state; a tab close wipes it.
const messagesWithSelfOverlay = useMemo(
() =>
messages.map((m) => {
const eid = String(m.event_id || '');
const selfText = eid ? selfAuthoredByEventId[eid] : '';
if (!selfText) return m;
return { ...m, decrypted_message: selfText };
}),
[messages, selfAuthoredByEventId],
);
const threadedMessages = useMemo(
() => buildThreadedList(messagesWithSelfOverlay),
[messagesWithSelfOverlay],
);
return (
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
<div className="flex items-center justify-between mb-2">
<button
onClick={onBack}
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50"
>
<ChevronLeft size={14} className="mr-1" />
RETURN TO MAIN
</button>
<div className="flex items-center gap-2">
<button
onClick={onBack}
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50"
>
<ChevronLeft size={14} className="mr-1" />
RETURN TO MAIN
</button>
{onOpenShutdownPetition && (
<button
onClick={() => onOpenShutdownPetition(gateName)}
title="Open gate shutdown lifecycle (suspend / shutdown / appeal)"
className="flex items-center text-amber-500 hover:text-amber-400 transition-all uppercase text-xs tracking-widest border border-amber-900/50 px-3 py-1 bg-amber-900/10 hover:bg-amber-900/30 hover:border-amber-500/50"
>
SHUTDOWN STATUS
</button>
)}
</div>
<div className="text-gray-500 text-xs">
LOGGED IN AS:{' '}
<span
@@ -530,11 +933,18 @@ export default function GateView({
<div className="flex items-center justify-between gap-4 mt-4">
<div>
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest">g/{gateId}</h1>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest">g/{gateId}</h1>
{compatActive ? (
<span className="border border-amber-500/40 bg-amber-950/20 px-2 py-0.5 text-[10px] font-mono tracking-[0.2em] text-amber-200">
COMPAT
</span>
) : null}
</div>
<p className="text-gray-500 text-sm mt-1">Fixed obfuscated gate. Creation is disabled for this testnet.</p>
</div>
<button
onClick={() => void refreshGate()}
onClick={() => void refreshGate({ force: true })}
className="inline-flex items-center gap-2 px-3 py-2 border border-cyan-500/30 bg-cyan-950/20 text-cyan-300 hover:bg-cyan-900/30 transition-colors text-sm uppercase tracking-[0.22em]"
>
<RefreshCw size={13} />
@@ -593,11 +1003,31 @@ export default function GateView({
)}
</div>
{roomError ? (
{roomError && !compatConsentPrompt ? (
<div className="mb-3 shrink-0 border border-red-900/30 bg-red-950/10 px-3 py-2 text-[11px] text-red-300">
{roomError}
</div>
) : null}
{compatConsentPrompt ? (
<div className="mb-3 shrink-0 border border-amber-500/30 bg-amber-950/15 px-3 py-2 text-[11px] text-amber-100/90">
<div className="text-[12px] font-mono tracking-[0.2em] text-amber-300">COMPAT MODE</div>
<div className="mt-1 leading-[1.7]">
{describeGateCompatConsentPrompt(compatConsentPrompt.action)}
</div>
<div className="mt-1 text-[11px] text-amber-200/70">
{describeGateCompatReason(compatConsentPrompt.reason, compatConsentPrompt.gateId)}
</div>
<div className="mt-2 flex items-center gap-2">
<button
onClick={approveCompatFallback}
className="px-3 py-1.5 border border-amber-500/40 bg-amber-950/20 text-[11px] font-mono tracking-[0.18em] text-amber-100 hover:bg-amber-900/30 transition-colors"
>
ENABLE FOR ROOM
</button>
<span className="text-[11px] text-amber-200/70">Weaker privacy on this device.</span>
</div>
</div>
) : null}
{voteNotice ? (
<div className="mb-2 shrink-0 border border-yellow-800/30 bg-yellow-950/10 px-3 py-1.5 text-sm text-yellow-400/80 font-mono">
{voteNotice}
@@ -644,10 +1074,14 @@ export default function GateView({
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-mono">
<span className="text-green-400" title={String(message.public_key || message.node_id || '')}>
@{String(message.node_id || '').replace(/^!sb_/, '').slice(0, 8)
|| String(message.public_key || '').slice(0, 8)
|| 'unknown'}
<span className="text-green-400">
@{String(
(message as unknown as { sender_handle?: string }).sender_handle
|| ((message as unknown as { payload?: { sender_handle?: string } }).payload?.sender_handle)
|| String(message.node_id || '').replace(/^!sb_/, '').slice(0, 8)
|| String(message.public_key || '').slice(0, 8)
|| 'anon_????',
)}
</span>
{isEncryptedGateEnvelope(message) ? (
<span
@@ -657,7 +1091,7 @@ export default function GateView({
: 'text-amber-300 border-amber-700/60'
}`}
>
{gateEnvelopeState(message) === 'decrypted' ? 'DECRYPTED' : 'KEY LOCKED'}
{gateEnvelopeState(message) === 'decrypted' ? 'DECRYPTED' : 'SEALED'}
</span>
) : null}
<span className="text-[var(--text-muted)] text-[13px]">{timeAgo(message.timestamp)}</span>
@@ -742,7 +1176,12 @@ export default function GateView({
<textarea
ref={textareaRef}
value={composer}
onChange={(e) => setComposer(e.target.value)}
onChange={(e) => {
setComposer(e.target.value);
if (roomError) {
setRoomError('');
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -1,7 +1,7 @@
'use client';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Terminal, Radio, Globe, Key, LogOut, Activity, Vote, User, ArrowRightLeft, Briefcase, Mail } from 'lucide-react';
import { Terminal, Radio, Globe, Key, LogOut, Activity, Vote, User, ArrowRightLeft, Briefcase, Mail, Brain, GitBranch, Cpu, KeyRound } from 'lucide-react';
import { getNodeIdentity, getWormholeIdentityDescriptor } from '@/mesh/meshIdentity';
import {
activateWormholeGatePersona,
@@ -19,6 +19,13 @@ import WeatherWidget from './WeatherWidget';
import TrendingPosts from './TrendingPosts';
import HashchainEvents from './HashchainEvents';
import NetworkStats from './NetworkStats';
import AIQueryView from './AIQueryView';
import PetitionsView from './PetitionsView';
import UpgradeView from './UpgradeView';
import ResolutionView from './ResolutionView';
import GateShutdownView from './GateShutdownView';
import BootstrapView from './BootstrapView';
import FunctionKeyView from './FunctionKeyView';
const ASCII_HEADER = `
@@ -38,11 +45,8 @@ const ASCII_HEADER = `
`;
const COMING_SOON_MODULES: Record<string, { title: string; desc: string; status: string }> = {
BALLOT: {
title: 'BALLOT — DEMOCRACY FOR ALL SOON',
desc: 'Governance surfaces are not live in this testnet shell yet. When they arrive, they should reflect real community demand, clear rules, and verifiable participation instead of placeholder politics.',
status: 'MODULE STATUS: HOLDING SCREEN ONLY — NO LIVE BALLOTS OR COUNTS',
},
// BALLOT entry removed 2026-04-28: the BALLOT command now navigates
// to PetitionsView (live governance DSL + petition lifecycle).
GIGS: {
title: 'GIGS — NETWORK BOUNTIES',
desc: 'Decentralized work contracts, intelligence bounties, and mesh task allocation. Accept jobs, deliver payloads, and earn credits through verified proof-of-work completion.',
@@ -60,6 +64,19 @@ const GATES = [
'ukraine-front', 'iran-front', 'world-news', 'prediction-markets',
'finance', 'cryptography', 'cryptocurrencies', 'meet-chat', 'opsec-lab'
];
const GATE_LAUNCH_RETRY_DELAY_MS = 3000;
const GATE_LAUNCH_RETRY_ATTEMPTS = 20;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
function isGateLaneStartingError(detail: string): boolean {
const lowered = String(detail || '').trim().toLowerCase();
return lowered.includes('obfuscated lane is still starting');
}
const SHELL_ANON_PERSONAS_KEY = 'sb_infonet_shell_anon_personas';
@@ -99,7 +116,11 @@ function allocateShellAnonPersona(): string {
const SECTIONS = [
{ name: 'HELP', icon: <Terminal size={14} className="mr-2" /> },
{ name: 'AI', icon: <Brain size={14} className="mr-2" /> },
{ name: 'BALLOT', icon: <Vote size={14} className="mr-2" /> },
{ name: 'UPGRADES', icon: <GitBranch size={14} className="mr-2" /> },
{ name: 'BOOTSTRAP', icon: <Cpu size={14} className="mr-2" /> },
{ name: 'F-KEYS', icon: <KeyRound size={14} className="mr-2" /> },
{ name: 'GIGS', icon: <Briefcase size={14} className="mr-2" /> },
{ name: 'MESH', icon: <Globe size={14} className="mr-2" /> },
{ name: 'GATES', icon: <Key size={14} className="mr-2" /> },
@@ -119,16 +140,26 @@ interface InfonetShellProps {
isOpen: boolean;
onClose: () => void;
onOpenLiveGate?: (gate: string) => void;
onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void;
}
export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: InfonetShellProps) {
export default function InfonetShell({
isOpen,
onClose,
onOpenLiveGate,
onOpenDeadDrop,
}: InfonetShellProps) {
const [input, setInput] = useState('');
const [history, setHistory] = useState<CommandHistory[]>([]);
const [isBooting, setIsBooting] = useState(true);
const [bootText, setBootText] = useState<string[]>([]);
// Navigation & State
const [currentView, setCurrentView] = useState<'terminal' | 'gate' | 'market' | 'profile' | 'messages'>('terminal');
type ViewName =
| 'terminal' | 'gate' | 'market' | 'profile' | 'messages' | 'ai'
| 'petitions' | 'upgrades' | 'resolution' | 'gate-shutdown'
| 'bootstrap' | 'function-keys';
const [currentView, setCurrentView] = useState<ViewName>('terminal');
const [activeGate, setActiveGate] = useState<string | null>(null);
const [persona, setPersona] = useState<string | null>(null);
const [activeGateMode, setActiveGateMode] = useState<'anonymous' | 'persona' | null>(null);
@@ -137,10 +168,15 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
const [isCitizen] = useState(false);
const [comingSoonModule, setComingSoonModule] = useState<string | null>(null);
const [wormholePromptKey, setWormholePromptKey] = useState('');
// Targets for parameterized economy views.
const [resolutionMarketId, setResolutionMarketId] = useState<string | null>(null);
const [shutdownGateId, setShutdownGateId] = useState<string | null>(null);
const [bootstrapMarketId, setBootstrapMarketId] = useState<string | null>(null);
const endOfTerminalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const gateLaunchAttemptRef = useRef(0);
// Real mesh identity
const nodeIdentity = useMemo(() => getNodeIdentity(), []);
@@ -167,6 +203,7 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
setInputMode('normal');
setPendingGate(null);
setInput('');
gateLaunchAttemptRef.current += 1;
setIsBooting(true);
setBootText([]);
@@ -235,7 +272,7 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
endOfTerminalRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [history]);
const handleNavigate = (view: 'terminal' | 'gate' | 'market' | 'profile' | 'messages', gate?: string) => {
const handleNavigate = (view: 'terminal' | 'gate' | 'market' | 'profile' | 'messages' | 'ai', gate?: string) => {
if (view === 'gate' && gate) {
if (onOpenLiveGate) {
setPendingGate(gate);
@@ -262,6 +299,58 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
setCurrentView(view);
};
const openGateWhenReady = async (
gateTarget: string,
operation: () => Promise<void>,
options: { commandLabel: string; waitingOutput: React.ReactNode; failurePrefix: string },
) => {
const launchId = ++gateLaunchAttemptRef.current;
let waitingShown = false;
for (let attempt = 0; attempt < GATE_LAUNCH_RETRY_ATTEMPTS; attempt += 1) {
if (gateLaunchAttemptRef.current !== launchId) {
return;
}
try {
await operation();
return;
} catch (error) {
const detail = error instanceof Error ? error.message : options.failurePrefix;
if (!isGateLaneStartingError(detail)) {
if (gateLaunchAttemptRef.current !== launchId) {
return;
}
setHistory(prev => [...prev, {
command: options.commandLabel,
output: <span className="text-red-400">ERR: {detail}</span>,
}]);
return;
}
if (!waitingShown) {
waitingShown = true;
setHistory(prev => [...prev, {
command: options.commandLabel,
output: options.waitingOutput,
}]);
}
if (attempt === GATE_LAUNCH_RETRY_ATTEMPTS - 1) {
if (gateLaunchAttemptRef.current !== launchId) {
return;
}
setHistory(prev => [...prev, {
command: options.commandLabel,
output: (
<span className="text-red-400">
ERR: The obfuscated lane is taking too long to come online. It is still warming up in the background.
</span>
),
}]);
return;
}
await sleep(GATE_LAUNCH_RETRY_DELAY_MS);
}
}
};
const handleCommand = (cmd: string) => {
const trimmedCmd = cmd.trim().toLowerCase();
let output: React.ReactNode = '';
@@ -293,18 +382,24 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
setHistory(prev => [...prev, { command: cmd, output }]);
setPendingGate(null);
void (async () => {
try {
await enterWormholeGate(gateTarget, true);
setActiveGateMode('anonymous');
setActiveGate(gateTarget);
setCurrentView('gate');
} catch (error) {
const detail = error instanceof Error ? error.message : 'anonymous_gate_enter_failed';
setHistory(prev => [...prev, {
command: `gate ${gateTarget}`,
output: <span className="text-red-400">ERR: {detail}</span>,
}]);
}
await openGateWhenReady(
gateTarget,
async () => {
await enterWormholeGate(gateTarget, true);
setActiveGateMode('anonymous');
setActiveGate(gateTarget);
setCurrentView('gate');
},
{
commandLabel: `gate ${gateTarget}`,
waitingOutput: (
<span className="text-cyan-400">
Warming the obfuscated lane for g/{gateTarget}. The room will open automatically as soon as it is ready.
</span>
),
failurePrefix: 'anonymous_gate_enter_failed',
},
);
})();
return;
}
@@ -312,30 +407,36 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
setHistory(prev => [...prev, { command: cmd, output }]);
setPendingGate(null);
void (async () => {
try {
const personas = await listWormholeGatePersonas(gateTarget);
const existing = Array.isArray(personas?.personas)
? personas.personas.find(
(candidate) =>
String(candidate?.label || '').trim().toLowerCase() === chosenPersona.toLowerCase(),
)
: null;
const result = existing?.persona_id
? await activateWormholeGatePersona(gateTarget, existing.persona_id)
: await createWormholeGatePersona(gateTarget, chosenPersona);
if (!result?.ok) {
throw new Error(result?.detail || 'gate_face_create_failed');
}
setActiveGateMode('persona');
setActiveGate(gateTarget);
setCurrentView('gate');
} catch (error) {
const detail = error instanceof Error ? error.message : 'gate_face_create_failed';
setHistory(prev => [...prev, {
command: `join ${gateTarget}`,
output: <span className="text-red-400">ERR: {detail}</span>,
}]);
}
await openGateWhenReady(
gateTarget,
async () => {
const personas = await listWormholeGatePersonas(gateTarget);
const existing = Array.isArray(personas?.personas)
? personas.personas.find(
(candidate) =>
String(candidate?.label || '').trim().toLowerCase() === chosenPersona.toLowerCase(),
)
: null;
const result = existing?.persona_id
? await activateWormholeGatePersona(gateTarget, existing.persona_id)
: await createWormholeGatePersona(gateTarget, chosenPersona);
if (!result?.ok) {
throw new Error(result?.detail || 'gate_face_create_failed');
}
setActiveGateMode('persona');
setActiveGate(gateTarget);
setCurrentView('gate');
},
{
commandLabel: `join ${gateTarget}`,
waitingOutput: (
<span className="text-cyan-400">
Warming the obfuscated lane for g/{gateTarget}. Your gate face will open automatically when the room is ready.
</span>
),
failurePrefix: 'gate_face_create_failed',
},
);
})();
return;
}
@@ -351,7 +452,12 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
<li><span className="text-gray-300 font-bold">radio</span> - Open SIGINT / radio surfaces</li>
<li><span className="text-gray-300 font-bold">messages</span> - Open Secure Comms</li>
<li><span className="text-gray-300 font-bold">profile</span> - View sovereign identity & ledger</li>
<li><span className="text-gray-300 font-bold">ballot</span> - View democratic proposals</li>
<li><span className="text-gray-300 font-bold">ballot / petitions / governance</span> - File / sign / vote on petitions (DSL executor)</li>
<li><span className="text-gray-300 font-bold">upgrades</span> - Upgrade-hash governance + Heavy-Node readiness</li>
<li><span className="text-gray-300 font-bold">resolution [market_id]</span> - Evidence + dispute view</li>
<li><span className="text-gray-300 font-bold">shutdown [gate_id]</span> - Gate suspend / shutdown / appeal lifecycle</li>
<li><span className="text-gray-300 font-bold">bootstrap</span> - Bootstrap-mode resolution + ramp milestones</li>
<li><span className="text-gray-300 font-bold">fkeys / function-keys</span> - Anonymous citizenship proof design</li>
<li><span className="text-gray-300 font-bold">gigs</span> - View network bounties & jobs</li>
<li><span className="text-gray-300 font-bold">markets</span> - View prediction markets</li>
<li><span className="text-gray-300 font-bold">exchange</span> - Decentralized crypto exchange</li>
@@ -387,6 +493,9 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
} else {
output = <span className="text-red-400">ERR: Gate &apos;{target}&apos; not found or access denied.</span>;
}
} else if (trimmedCmd === 'ai' || trimmedCmd === 'copilot' || trimmedCmd === 'openclaw') {
handleNavigate('ai');
return;
} else if (trimmedCmd === 'markets') {
handleNavigate('market');
return;
@@ -396,9 +505,35 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
} else if (trimmedCmd === 'profile') {
handleNavigate('profile');
return;
} else if (trimmedCmd === 'ballot') {
setComingSoonModule('BALLOT');
} else if (trimmedCmd === 'ballot' || trimmedCmd === 'petitions' || trimmedCmd === 'governance') {
setCurrentView('petitions');
return;
} else if (trimmedCmd === 'upgrades' || trimmedCmd === 'upgrade') {
setCurrentView('upgrades');
return;
} else if (trimmedCmd === 'bootstrap') {
setBootstrapMarketId(null);
setCurrentView('bootstrap');
return;
} else if (trimmedCmd === 'function-keys' || trimmedCmd === 'fkeys') {
setCurrentView('function-keys');
return;
} else if (trimmedCmd.startsWith('resolution ')) {
const mid = trimmedCmd.slice('resolution '.length).trim();
if (mid) {
setResolutionMarketId(mid);
setCurrentView('resolution');
return;
}
output = <span className="text-red-400">Usage: resolution &lt;market_id&gt;</span>;
} else if (trimmedCmd.startsWith('shutdown ')) {
const gid = trimmedCmd.slice('shutdown '.length).trim();
if (gid) {
setShutdownGateId(gid);
setCurrentView('gate-shutdown');
return;
}
output = <span className="text-red-400">Usage: shutdown &lt;gate_id&gt;</span>;
} else if (trimmedCmd === 'work' || trimmedCmd === 'gigs') {
setComingSoonModule('GIGS');
return;
@@ -495,7 +630,11 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
{SECTIONS.map((section) => (
<button
key={section.name}
onClick={() => handleCommand(section.name === 'PROFILE' ? 'profile' : section.name.toLowerCase())}
onClick={() => handleCommand(
section.name === 'PROFILE' ? 'profile' :
section.name === 'F-KEYS' ? 'fkeys' :
section.name.toLowerCase()
)}
className="flex items-center px-2 py-1 bg-cyan-900/10 border border-cyan-900/50 text-cyan-500 hover:bg-cyan-900/30 hover:text-cyan-400 hover:border-cyan-500/50 transition-all text-sm md:text-xs uppercase tracking-widest whitespace-nowrap"
>
{section.icon}
@@ -593,6 +732,10 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
onBack={() => handleNavigate('terminal')}
onNavigateGate={(gate) => handleNavigate('gate', gate)}
onOpenLiveGate={onOpenLiveGate}
onOpenShutdownPetition={(gate) => {
setShutdownGateId(gate);
setCurrentView('gate-shutdown');
}}
availableGates={GATES}
/>
)}
@@ -612,7 +755,44 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
)}
{currentView === 'messages' && (
<MessagesView onBack={() => handleNavigate('terminal')} />
<MessagesView onBack={() => handleNavigate('terminal')} onOpenDeadDrop={onOpenDeadDrop} />
)}
{currentView === 'ai' && (
<AIQueryView onBack={() => handleNavigate('terminal')} />
)}
{currentView === 'petitions' && (
<PetitionsView onBack={() => setCurrentView('terminal')} />
)}
{currentView === 'upgrades' && (
<UpgradeView onBack={() => setCurrentView('terminal')} />
)}
{currentView === 'resolution' && resolutionMarketId && (
<ResolutionView
marketId={resolutionMarketId}
onBack={() => setCurrentView('terminal')}
/>
)}
{currentView === 'gate-shutdown' && shutdownGateId && (
<GateShutdownView
gateId={shutdownGateId}
onBack={() => setCurrentView('terminal')}
/>
)}
{currentView === 'bootstrap' && (
<BootstrapView
marketId={bootstrapMarketId ?? undefined}
onBack={() => setCurrentView('terminal')}
/>
)}
{currentView === 'function-keys' && (
<FunctionKeyView onBack={() => setCurrentView('terminal')} />
)}
{/* Coming Soon Popup */}
@@ -1,11 +1,12 @@
'use client';
import React, { useState } from 'react';
import { ChevronLeft, Search, Activity, Shield, Crosshair, DollarSign, Newspaper } from 'lucide-react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { ChevronLeft, Search, Activity, Shield, Crosshair, DollarSign, Newspaper, ExternalLink, Loader } from 'lucide-react';
import { useDataKeys } from '@/hooks/useDataStore';
import { API_BASE } from '@/lib/api';
import type { DashboardData, StockTicker } from '@/types/dashboard';
function formatVolume(vol: number): string {
function formatVolume(vol: number | null | undefined): string {
if (!vol || vol <= 0) return '';
if (vol >= 1_000_000) return `$${(vol / 1_000_000).toFixed(1)}M`;
if (vol >= 1_000) return `$${(vol / 1_000).toFixed(0)}K`;
@@ -32,33 +33,185 @@ const CATEGORY_CONFIG: Record<string, { color: string; icon: typeof Shield }> =
CONFLICT: { color: 'text-red-400', icon: Crosshair },
FINANCE: { color: 'text-emerald-400', icon: DollarSign },
CRYPTO: { color: 'text-amber-400', icon: DollarSign },
SPORTS: { color: 'text-orange-400', icon: Activity },
NEWS: { color: 'text-cyan-400', icon: Newspaper },
};
type Category = 'ALL' | 'POLITICS' | 'CONFLICT' | 'FINANCE' | 'CRYPTO' | 'NEWS';
type Category = 'ALL' | 'POLITICS' | 'CONFLICT' | 'FINANCE' | 'CRYPTO' | 'SPORTS' | 'NEWS';
interface MarketViewProps {
onBack: () => void;
}
type MarketSource = {
name: string;
pct: number;
};
type MarketOutcome = {
name: string;
pct: number;
};
type PredictionMarket = {
title: string;
category?: Category | string;
consensus_pct?: number | null;
polymarket_pct?: number | null;
kalshi_pct?: number | null;
volume?: number | null;
volume_24h?: number | null;
end_date?: string | null;
description?: string | null;
sources?: MarketSource[];
slug?: string;
kalshi_ticker?: string;
outcomes?: MarketOutcome[];
delta_pct?: number | null;
consensus?: {
total_picks: number;
total_staked: number;
};
};
type DataSlice = Pick<DashboardData, 'trending_markets' | 'stocks'>;
const DATA_KEYS = ['trending_markets', 'stocks'] as const;
export default function MarketView({ onBack }: MarketViewProps) {
const [category, setCategory] = useState<Category>('ALL');
const [searchInput, setSearchInput] = useState('');
const [searchResults, setSearchResults] = useState<PredictionMarket[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [allMarkets, setAllMarkets] = useState<PredictionMarket[]>([]);
const [marketTotals, setMarketTotals] = useState<Record<string, number>>({});
const [marketHasMore, setMarketHasMore] = useState<Record<string, boolean>>({});
const [searchHasMore, setSearchHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [allBrowseOffset, setAllBrowseOffset] = useState(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const data = useDataKeys(DATA_KEYS) as DataSlice;
const markets = data?.trending_markets || [];
const stocks = data?.stocks;
const filteredMarkets = markets.filter(m => {
const appendUniqueMarkets = useCallback((existing: PredictionMarket[], incoming: PredictionMarket[]) => {
const seen = new Set(existing.map((m) => String(m.slug || m.kalshi_ticker || m.title).toLowerCase()));
const next = [...existing];
for (const market of incoming) {
const key = String(market.slug || market.kalshi_ticker || market.title).toLowerCase();
if (!seen.has(key)) {
seen.add(key);
next.push(market);
}
}
return next;
}, []);
// Fetch all markets from the oracle endpoint on mount
useEffect(() => {
let mounted = true;
(async () => {
try {
const res = await fetch(`${API_BASE}/api/mesh/oracle/markets`);
if (res.ok) {
const d = await res.json();
const cats = d.categories || {};
const all: PredictionMarket[] = [];
for (const cat of Object.values(cats) as PredictionMarket[][]) {
all.push(...cat);
}
if (mounted) {
setAllMarkets(appendUniqueMarkets([], all));
const totals = d.cat_totals || {};
setMarketTotals({ ...totals, ALL: d.total_count || all.length });
const more: Record<string, boolean> = {};
for (const [cat, count] of Object.entries(totals)) {
const loaded = Array.isArray(cats[cat]) ? cats[cat].length : 0;
more[cat] = Number(count) > loaded;
}
more.ALL = Number(d.total_count || 0) > all.length;
setMarketHasMore(more);
}
}
} catch { /* silent */ }
})();
return () => { mounted = false; };
}, [appendUniqueMarkets]);
// API search — hits Polymarket + Kalshi directly
const searchMarkets = useCallback(async (query: string, offset = 0) => {
if (query.length < 2) {
setSearchResults([]);
setSearchHasMore(false);
setIsSearching(false);
return;
}
setIsSearching(true);
try {
const res = await fetch(
`${API_BASE}/api/mesh/oracle/search?q=${encodeURIComponent(query)}&limit=50&offset=${offset}`,
);
if (res.ok) {
const d = await res.json();
const results = d.results || [];
setSearchResults((prev) => (offset > 0 ? appendUniqueMarkets(prev, results) : results));
setSearchHasMore(Boolean(d.has_more));
}
} catch { /* silent */ }
setIsSearching(false);
}, [appendUniqueMarkets]);
const loadMoreMarkets = useCallback(async () => {
if (loadingMore) return;
setLoadingMore(true);
try {
if (searchInput.length >= 2) {
await searchMarkets(searchInput, searchResults.length);
return;
}
const loadedForCategory =
category === 'ALL'
? allBrowseOffset
: allMarkets.filter((m) => m.category === category).length;
const res = await fetch(
`${API_BASE}/api/mesh/oracle/markets/more?category=${encodeURIComponent(category)}&offset=${loadedForCategory}&limit=50`,
);
if (res.ok) {
const d = await res.json();
const markets = d.markets || [];
setAllMarkets((prev) => appendUniqueMarkets(prev, markets));
if (category === 'ALL') {
setAllBrowseOffset((prev) => prev + markets.length);
}
setMarketHasMore((prev) => ({ ...prev, [category]: Boolean(d.has_more) }));
setMarketTotals((prev) => ({ ...prev, [category]: d.total ?? prev[category] ?? loadedForCategory }));
}
} catch { /* silent */ }
finally {
setLoadingMore(false);
}
}, [allBrowseOffset, allMarkets, appendUniqueMarkets, category, loadingMore, searchInput, searchMarkets, searchResults.length]);
const handleSearchInput = useCallback(
(value: string) => {
setSearchInput(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => searchMarkets(value), 400);
},
[searchMarkets],
);
// Use search results when searching, otherwise show all markets
const displayMarkets = searchInput.length >= 2 ? searchResults : allMarkets;
const filteredMarkets = displayMarkets.filter(m => {
const matchesCat = category === 'ALL' || m.category === category;
const matchesSearch = !searchInput || m.title.toLowerCase().includes(searchInput.toLowerCase());
return matchesCat && matchesSearch;
return matchesCat;
});
const CATEGORIES: Category[] = ['ALL', 'POLITICS', 'CONFLICT', 'FINANCE', 'CRYPTO', 'NEWS'];
const CATEGORIES: Category[] = ['ALL', 'POLITICS', 'CONFLICT', 'FINANCE', 'CRYPTO', 'SPORTS', 'NEWS'];
const currentTotal = searchInput.length >= 2
? null
: marketTotals[category] ?? filteredMarkets.length;
const canLoadMore = searchInput.length >= 2 ? searchHasMore : Boolean(marketHasMore[category]);
// Build ticker from real stocks data
const tickerItems: string[] = [];
@@ -87,7 +240,10 @@ export default function MarketView({ onBack }: MarketViewProps) {
<Activity className="mr-2 text-cyan-400 animate-pulse" />
PREDICTION MARKETS
</h1>
<p className="text-gray-500 text-sm mt-1">Live Polymarket + Kalshi feeds. {markets.length} active markets tracked.</p>
<p className="text-gray-500 text-sm mt-1">
Live Polymarket + Kalshi feeds. Search anything all markets from both platforms.
{' '}{allMarkets.length > 0 && `${allMarkets.length} cached markets.`}
</p>
</div>
{/* Categories */}
@@ -107,32 +263,45 @@ export default function MarketView({ onBack }: MarketViewProps) {
</button>
))}
</div>
<span className="text-sm text-gray-500 font-mono">{filteredMarkets.length} RESULTS</span>
<span className="text-sm text-gray-500 font-mono">
{filteredMarkets.length}{currentTotal != null && currentTotal > filteredMarkets.length ? ` / ${currentTotal}` : ''} RESULTS
</span>
</div>
{/* Search Bar */}
<div className="mb-4 shrink-0">
<div className="flex items-center border border-gray-800 bg-[#0a0a0a] p-2">
<Search size={14} className="text-gray-600 mr-2" />
{isSearching ? (
<Loader size={14} className="text-cyan-500 mr-2 animate-spin" />
) : (
<Search size={14} className="text-gray-600 mr-2" />
)}
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search prediction markets..."
onChange={(e) => handleSearchInput(e.target.value)}
placeholder="Search ALL Polymarket + Kalshi markets (e.g. avalanche, bitcoin, trump, war)..."
className="bg-transparent border-none outline-none text-white w-full text-sm placeholder-gray-700"
spellCheck={false}
/>
</div>
{searchInput.length >= 2 && (
<div className="text-xs font-mono text-gray-600 mt-1 px-1">
{isSearching
? 'SEARCHING POLYMARKET + KALSHI APIs...'
: `${searchResults.length} RESULTS FROM POLYMARKET + KALSHI`}
</div>
)}
</div>
{/* Markets List */}
<div className="flex-1 overflow-y-auto pr-2 space-y-3 pb-4">
{filteredMarkets.length > 0 ? filteredMarkets.map((market, i) => {
const pct = market.consensus_pct ?? market.polymarket_pct ?? market.kalshi_pct ?? 0;
const catConfig = CATEGORY_CONFIG[market.category] || { color: 'text-gray-400' };
const categoryLabel = market.category ?? 'UNCATEGORIZED';
const catConfig = CATEGORY_CONFIG[categoryLabel] || { color: 'text-gray-400' };
const vol = formatVolume(market.volume);
const vol24 = formatVolume(market.volume_24h);
// Runtime-optional fields the backend may send but aren't in the strict TS type
const raw = market as Record<string, unknown>;
const endDate = formatEndDate(typeof raw.end_date === 'string' ? raw.end_date : null);
const outcomes = market.outcomes && market.outcomes.length > 0 ? market.outcomes : null;
@@ -145,7 +314,7 @@ export default function MarketView({ onBack }: MarketViewProps) {
<div className="flex-1">
<div className="text-gray-300 font-bold text-sm md:text-base leading-snug">{market.title}</div>
<div className="flex items-center gap-2 mt-1.5 text-sm font-mono">
<span className={`${catConfig.color} uppercase tracking-widest`}>{market.category}</span>
<span className={`${catConfig.color} uppercase tracking-widest`}>{categoryLabel}</span>
{vol && <span className="text-gray-500">VOL: {vol}</span>}
{vol24 && <span className="text-gray-500">24H: {vol24}</span>}
{endDate && <span className="text-gray-500">CLOSES: {endDate}</span>}
@@ -187,7 +356,7 @@ export default function MarketView({ onBack }: MarketViewProps) {
</div>
)}
{/* Source badges */}
{/* Source badges + external links */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-1.5 flex-wrap">
{market.sources?.map((s, si) => (
@@ -205,6 +374,23 @@ export default function MarketView({ onBack }: MarketViewProps) {
{consensus.total_staked > 0 ? ` · ${consensus.total_staked.toFixed(1)} REP` : ''}
</span>
)}
{/* External links */}
{market.slug && (
<button
onClick={() => window.open(`https://polymarket.com/event/${market.slug}`, '_blank', 'noopener,noreferrer')}
className="flex items-center gap-1 text-[11px] font-mono px-1.5 py-0.5 border border-purple-500/30 bg-purple-500/10 text-purple-400 hover:bg-purple-500/20 cursor-pointer"
>
<ExternalLink size={9} /> POLY
</button>
)}
{market.kalshi_ticker && (
<button
onClick={() => window.open(`https://kalshi.com/markets/${market.kalshi_ticker}`, '_blank', 'noopener,noreferrer')}
className="flex items-center gap-1 text-[11px] font-mono px-1.5 py-0.5 border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 cursor-pointer"
>
<ExternalLink size={9} /> KALSHI
</button>
)}
</div>
{/* Delta indicator */}
@@ -233,11 +419,31 @@ export default function MarketView({ onBack }: MarketViewProps) {
);
}) : (
<div className="text-center text-gray-600 py-8">
<p className="text-sm italic">No markets found{searchInput ? ` for "${searchInput}"` : ''}.</p>
{isSearching ? (
<p className="text-sm">Searching Polymarket + Kalshi...</p>
) : (
<p className="text-sm italic">No markets found{searchInput ? ` for "${searchInput}"` : ''}.</p>
)}
</div>
)}
</div>
{canLoadMore && (
<div className="shrink-0 flex justify-center pb-3">
<button
onClick={() => void loadMoreMarkets()}
disabled={loadingMore || isSearching}
className="px-4 py-2 text-xs uppercase tracking-widest border border-cyan-900/50 bg-cyan-900/10 text-cyan-400 hover:border-cyan-500/50 hover:bg-cyan-900/30 disabled:opacity-50"
>
{loadingMore || isSearching
? 'LOADING MORE...'
: searchInput.length >= 2
? 'MORE SEARCH RESULTS'
: `MORE ${category} MARKETS`}
</button>
</div>
)}
{/* Ticker */}
{tickerItems.length > 0 && (
<div className="shrink-0 border-t border-gray-800 bg-gray-900/30 overflow-hidden py-2 mt-2">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,618 @@
'use client';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronLeft, FileText, Vote, Shield, AlertCircle, CheckCircle2, Loader } from 'lucide-react';
import {
buildChallengeFilePayload,
buildPetitionFilePayload,
buildPetitionSignPayload,
buildPetitionVotePayload,
fetchPetitions,
freshLocalId,
previewPetitionPayload,
signAndAppend,
type PetitionPayload,
type PetitionState,
} from '@/mesh/infonetEconomyClient';
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
interface PetitionsViewProps {
onBack: () => void;
}
const STATUS_STYLE: Record<string, { color: string; label: string; icon: typeof Vote }> = {
signatures: { color: 'text-cyan-400', label: 'COLLECTING SIGNATURES', icon: FileText },
voting: { color: 'text-blue-400', label: 'VOTING', icon: Vote },
challenge: { color: 'text-amber-400', label: 'CHALLENGE WINDOW', icon: Shield },
passed: { color: 'text-green-400', label: 'PASSED', icon: CheckCircle2 },
executed: { color: 'text-green-500', label: 'EXECUTED', icon: CheckCircle2 },
failed_signatures: { color: 'text-red-400', label: 'FAILED — SIGNATURES', icon: AlertCircle },
failed_vote: { color: 'text-red-400', label: 'FAILED — VOTE', icon: AlertCircle },
voided_challenge: { color: 'text-red-500', label: 'VOIDED BY CHALLENGE', icon: AlertCircle },
not_found: { color: 'text-gray-500', label: 'NOT FOUND', icon: AlertCircle },
};
function formatRelative(ts: number, now: number): string {
if (!ts) return '—';
const delta = ts - now;
const abs = Math.abs(delta);
const days = Math.floor(abs / 86400);
const hours = Math.floor((abs % 86400) / 3600);
if (delta > 0) {
if (days > 0) return `in ${days}d ${hours}h`;
if (hours > 0) return `in ${hours}h`;
return `in ${Math.floor(abs / 60)}m`;
} else {
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
return `${Math.floor(abs / 60)}m ago`;
}
}
function PayloadSummary({ payload }: { payload: PetitionPayload | Record<string, unknown> }) {
const t = (payload as { type?: string }).type;
if (t === 'UPDATE_PARAM') {
const p = payload as Extract<PetitionPayload, { type: 'UPDATE_PARAM' }>;
return (
<span className="text-gray-300">
Set <span className="text-cyan-400 font-bold">{p.key}</span> = {' '}
<span className="text-white font-bold">{String(p.value)}</span>
</span>
);
}
if (t === 'BATCH_UPDATE_PARAMS') {
const p = payload as Extract<PetitionPayload, { type: 'BATCH_UPDATE_PARAMS' }>;
return (
<span className="text-gray-300">
Update {p.updates?.length ?? 0} parameters atomically
</span>
);
}
if (t === 'ENABLE_FEATURE') {
const p = payload as Extract<PetitionPayload, { type: 'ENABLE_FEATURE' }>;
return <span className="text-gray-300">Enable feature <span className="text-green-400">{p.feature}</span></span>;
}
if (t === 'DISABLE_FEATURE') {
const p = payload as Extract<PetitionPayload, { type: 'DISABLE_FEATURE' }>;
return <span className="text-gray-300">Disable feature <span className="text-red-400">{p.feature}</span></span>;
}
return <span className="text-gray-500">Unknown payload type</span>;
}
function PetitionRow({
petition,
now,
onAction,
}: {
petition: PetitionState;
now: number;
onAction: () => void;
}) {
const style = STATUS_STYLE[petition.status] ?? STATUS_STYLE.not_found;
const Icon = style.icon;
const sigPct = petition.signature_threshold_at_filing > 0
? (petition.signature_governance_weight / petition.signature_threshold_at_filing) * 100
: 0;
const totalVotes = petition.votes_for_weight + petition.votes_against_weight;
const yesPct = totalVotes > 0
? (petition.votes_for_weight / totalVotes) * 100
: 0;
const { state, result, submit } = useSignAndAppend();
const busy = state === 'submitting';
const sign = useCallback(async () => {
const built = buildPetitionSignPayload(petition.petition_id);
const res = await submit(built.event_type, built.payload);
if (res.ok) onAction();
}, [petition.petition_id, submit, onAction]);
const voteFor = useCallback(async () => {
const built = buildPetitionVotePayload(petition.petition_id, 'for');
const res = await submit(built.event_type, built.payload);
if (res.ok) onAction();
}, [petition.petition_id, submit, onAction]);
const voteAgainst = useCallback(async () => {
const built = buildPetitionVotePayload(petition.petition_id, 'against');
const res = await submit(built.event_type, built.payload);
if (res.ok) onAction();
}, [petition.petition_id, submit, onAction]);
const challenge = useCallback(async () => {
const reason = window.prompt(
'Constitutional challenge — describe why this petition violates the constitution:',
);
if (!reason || !reason.trim()) return;
const built = buildChallengeFilePayload(petition.petition_id, reason.trim());
const res = await submit(built.event_type, built.payload);
if (res.ok) onAction();
}, [petition.petition_id, submit, onAction]);
return (
<div className="border border-gray-800 bg-black/40 p-3 hover:bg-black/60 transition-colors">
<div className="flex items-center justify-between gap-3 mb-2">
<div className="flex items-center gap-2 min-w-0">
<Icon size={14} className={style.color} />
<span className={`text-xs font-bold uppercase tracking-wider ${style.color}`}>
{style.label}
</span>
</div>
<span className="text-xs text-gray-500 font-mono truncate">
{petition.petition_id.slice(0, 16)}
</span>
</div>
<div className="text-sm mb-2">
<PayloadSummary payload={petition.petition_payload} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<div>
<div className="text-gray-500">Filer</div>
<div className="text-gray-300 font-mono truncate" title={petition.filer_id}>
{petition.filer_id.slice(0, 12)}
</div>
</div>
<div>
<div className="text-gray-500">Filed</div>
<div className="text-gray-300">{formatRelative(petition.filed_at, now)}</div>
</div>
{petition.status === 'signatures' && (
<div className="col-span-2">
<div className="text-gray-500">
Signatures: {petition.signature_governance_weight.toFixed(1)} / {petition.signature_threshold_at_filing.toFixed(1)}
</div>
<div className="h-1 bg-gray-800 mt-1 overflow-hidden">
<div
className="h-full bg-cyan-500 transition-all"
style={{ width: `${Math.min(100, sigPct)}%` }}
/>
</div>
</div>
)}
{(petition.status === 'voting' || petition.status === 'challenge'
|| petition.status === 'passed' || petition.status === 'executed') && (
<div className="col-span-2">
<div className="text-gray-500">
Vote: {petition.votes_for_weight.toFixed(1)} for / {petition.votes_against_weight.toFixed(1)} against
</div>
<div className="h-1 bg-gray-800 mt-1 overflow-hidden flex">
<div
className="h-full bg-green-500 transition-all"
style={{ width: `${yesPct}%` }}
/>
<div
className="h-full bg-red-500 transition-all"
style={{ width: `${100 - yesPct}%` }}
/>
</div>
</div>
)}
</div>
{petition.voting_deadline && petition.status === 'voting' && (
<div className="text-xs text-gray-500 mt-2">
Voting closes {formatRelative(petition.voting_deadline, now)}
</div>
)}
{petition.challenge_window_until && petition.status === 'challenge' && (
<div className="text-xs text-amber-400 mt-2">
Challenge window closes {formatRelative(petition.challenge_window_until, now)}
</div>
)}
<div className="flex flex-wrap gap-2 mt-3">
{petition.status === 'signatures' && (
<button
type="button"
onClick={sign}
disabled={busy}
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-cyan-700/50 bg-cyan-900/20 text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-30"
>
{busy ? 'Signing…' : 'Sign'}
</button>
)}
{petition.status === 'voting' && (
<>
<button
type="button"
onClick={voteFor}
disabled={busy}
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-green-700/50 bg-green-900/20 text-green-400 hover:bg-green-900/40 disabled:opacity-30"
>
{busy ? '…' : 'Vote FOR'}
</button>
<button
type="button"
onClick={voteAgainst}
disabled={busy}
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
>
{busy ? '…' : 'Vote AGAINST'}
</button>
</>
)}
{petition.status === 'challenge' && (
<button
type="button"
onClick={challenge}
disabled={busy}
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-amber-700/50 bg-amber-900/20 text-amber-400 hover:bg-amber-900/40 disabled:opacity-30"
title="File a constitutional challenge against this passed petition"
>
{busy ? '…' : 'Challenge'}
</button>
)}
</div>
{result && !result.ok && (
<div className="text-xs text-red-400 font-mono mt-2 break-all">
<AlertCircle size={10} className="inline mr-1" />
{result.reason}
</div>
)}
</div>
);
}
function FilePetitionForm({ onFiled }: { onFiled?: () => void }) {
const [paramKey, setParamKey] = useState('');
const [paramValue, setParamValue] = useState('');
const [previewing, setPreviewing] = useState(false);
const [filing, setFiling] = useState(false);
const [previewResult, setPreviewResult] = useState<
{ ok: true; changedKeys: string[]; newValues: Record<string, unknown> } |
{ ok: false; reason: string } | null
>(null);
const [fileResult, setFileResult] = useState<
{ ok: true; eventId: string } |
{ ok: false; reason: string } | null
>(null);
const handlePreview = useCallback(async () => {
if (!paramKey.trim()) return;
setPreviewing(true);
setPreviewResult(null);
try {
// Try numeric coercion; fall back to string. Backend validator
// rejects type mismatches with a diagnostic — surfaces directly.
let value: unknown = paramValue;
const numeric = Number(paramValue);
if (paramValue.trim() !== '' && !Number.isNaN(numeric)) {
value = numeric;
} else if (paramValue.trim().toLowerCase() === 'true') {
value = true;
} else if (paramValue.trim().toLowerCase() === 'false') {
value = false;
}
const payload: PetitionPayload = {
type: 'UPDATE_PARAM',
key: paramKey.trim(),
value,
};
const res = await previewPetitionPayload(payload);
if (res.ok) {
setPreviewResult({
ok: true,
changedKeys: res.changed_keys ?? [],
newValues: res.new_values ?? {},
});
} else {
setPreviewResult({ ok: false, reason: res.reason ?? 'unknown_error' });
}
} catch (err) {
setPreviewResult({
ok: false,
reason: err instanceof Error ? err.message : 'network_error',
});
} finally {
setPreviewing(false);
}
}, [paramKey, paramValue]);
const buildPayload = useCallback((): PetitionPayload | null => {
if (!paramKey.trim()) return null;
let value: unknown = paramValue;
const numeric = Number(paramValue);
if (paramValue.trim() !== '' && !Number.isNaN(numeric)) {
value = numeric;
} else if (paramValue.trim().toLowerCase() === 'true') {
value = true;
} else if (paramValue.trim().toLowerCase() === 'false') {
value = false;
}
return { type: 'UPDATE_PARAM', key: paramKey.trim(), value };
}, [paramKey, paramValue]);
const handleFile = useCallback(async () => {
const inner = buildPayload();
if (!inner) return;
setFiling(true);
setFileResult(null);
try {
// Generate a fresh petition_id deterministically from the payload
// + timestamp so refile attempts produce distinct IDs.
const petitionId = `pet-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
const built = buildPetitionFilePayload(petitionId, inner);
const res = await signAndAppend({
event_type: built.event_type,
payload: built.payload,
});
if (res.ok) {
setFileResult({ ok: true, eventId: res.event.event_id });
onFiled?.();
} else {
setFileResult({ ok: false, reason: res.reason });
}
} catch (err) {
setFileResult({
ok: false,
reason: err instanceof Error ? err.message : 'unknown_error',
});
} finally {
setFiling(false);
}
}, [buildPayload, onFiled]);
return (
<div className="border border-cyan-900/50 bg-cyan-900/5 p-3">
<div className="flex items-center gap-2 mb-3">
<FileText size={14} className="text-cyan-400" />
<span className="text-xs font-bold uppercase tracking-wider text-cyan-400">
File or Preview a Petition
</span>
</div>
<div className="text-xs text-gray-500 mb-3">
<span className="text-cyan-400 font-bold">Preview</span> runs the
governance DSL executor without touching the chain the diagnostic
on failure is shown verbatim.
{' '}<span className="text-amber-400 font-bold">File</span> signs the
same payload with your local node key and posts it to{' '}
<span className="font-mono">/api/infonet/append</span>; the secure
entry point ({' '}<span className="font-mono">Infonet.append</span>)
verifies signature, replay, sequence, and binding before the event
lands.
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-2">
<div>
<label className="text-xs text-gray-500 mb-1 block">CONFIG key</label>
<input
type="text"
value={paramKey}
onChange={(e) => setParamKey(e.target.value)}
placeholder="e.g. vote_decay_days"
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-sm text-white font-mono focus:border-cyan-500 focus:outline-none"
spellCheck={false}
/>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block">New value</label>
<input
type="text"
value={paramValue}
onChange={(e) => setParamValue(e.target.value)}
placeholder="e.g. 30 / true / argon2id"
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-sm text-white font-mono focus:border-cyan-500 focus:outline-none"
spellCheck={false}
/>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handlePreview}
disabled={previewing || !paramKey.trim()}
className="px-3 py-1 bg-cyan-900/30 border border-cyan-700/50 text-cyan-400 hover:bg-cyan-900/50 hover:text-cyan-300 transition-colors text-xs uppercase tracking-wider disabled:opacity-30 disabled:cursor-not-allowed"
>
{previewing ? 'Validating…' : 'Preview'}
</button>
<button
type="button"
onClick={handleFile}
disabled={filing || !paramKey.trim()}
className="px-3 py-1 bg-amber-900/30 border border-amber-700/50 text-amber-400 hover:bg-amber-900/50 hover:text-amber-300 transition-colors text-xs uppercase tracking-wider disabled:opacity-30 disabled:cursor-not-allowed"
title="Sign with the local node key + post to /api/infonet/append"
>
{filing ? 'Filing…' : 'File Petition'}
</button>
</div>
{fileResult && fileResult.ok && (
<div className="mt-3 border border-green-900/50 bg-green-900/10 p-2 text-xs">
<div className="text-green-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
<CheckCircle2 size={12} /> PETITION FILED
</div>
<div className="text-gray-300 font-mono break-all">
event_id: {fileResult.eventId}
</div>
<div className="text-gray-500 mt-1">
The petition is now in the SIGNATURES phase. Other nodes can
sign with <span className="font-mono">petition_sign</span>;
voting opens once 25% oracle_rep_active worth of signatures land.
</div>
</div>
)}
{fileResult && !fileResult.ok && (
<div className="mt-3 border border-red-900/50 bg-red-900/10 p-2 text-xs">
<div className="text-red-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
<AlertCircle size={12} /> FILING REJECTED
</div>
<div className="text-gray-300 font-mono break-all">{fileResult.reason}</div>
<div className="text-gray-500 mt-1">
Common causes: local identity not initialized
(open the InfonetTerminal first), filer rep below
petition_filing_cost, or the chain rejected the signed event.
Use Preview first to confirm the payload validates.
</div>
</div>
)}
{previewResult && previewResult.ok && (
<div className="mt-3 border border-green-900/50 bg-green-900/10 p-2 text-xs">
<div className="text-green-400 font-bold uppercase tracking-wider mb-1">
VALIDATION PASSED
</div>
<div className="text-gray-300">
Would change keys: {previewResult.changedKeys.map((k) => (
<span key={k} className="text-cyan-400 font-mono mr-2">{k}</span>
))}
</div>
<div className="text-gray-500 mt-1">
Filing this petition costs the configured petition_filing_cost in common rep.
Production filing requires a signed event this is the validation preview only.
</div>
</div>
)}
{previewResult && !previewResult.ok && (
<div className="mt-3 border border-red-900/50 bg-red-900/10 p-2 text-xs">
<div className="text-red-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
<AlertCircle size={12} /> VALIDATION REJECTED
</div>
<div className="text-gray-300 font-mono">{previewResult.reason}</div>
</div>
)}
</div>
);
}
export default function PetitionsView({ onBack }: PetitionsViewProps) {
const [petitions, setPetitions] = useState<PetitionState[] | null>(null);
const [now, setNow] = useState(Date.now() / 1000);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchPetitions();
setPetitions(data.petitions);
setNow(data.now);
} catch (err) {
setError(err instanceof Error ? err.message : 'network error');
} finally {
setLoading(false);
}
}, []);
const hasActivePhase = (petitions || []).some((p) =>
p.status === 'signatures' || p.status === 'voting' || p.status === 'challenge',
);
useEffect(() => {
void reload();
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 30_000);
return () => clearInterval(interval);
}, [reload, hasActivePhase]);
const grouped = useMemo(() => {
if (!petitions) return null;
const active = petitions.filter((p) =>
['signatures', 'voting', 'challenge'].includes(p.status),
);
const passed = petitions.filter((p) =>
['passed', 'executed'].includes(p.status),
);
const closed = petitions.filter((p) =>
['failed_signatures', 'failed_vote', 'voided_challenge'].includes(p.status),
);
return { active, passed, closed };
}, [petitions]);
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
<button
onClick={onBack}
className="flex items-center text-cyan-400 hover:text-cyan-300 transition-colors text-sm"
>
<ChevronLeft size={14} className="mr-1" />
BACK TO TERMINAL
</button>
<div className="text-sm text-cyan-400 font-bold uppercase tracking-widest flex items-center gap-2">
<Vote size={16} />
BALLOT Governance Petitions
</div>
<button
onClick={() => void reload()}
disabled={loading}
className="text-xs text-gray-500 hover:text-cyan-400 disabled:opacity-30"
>
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
</button>
</div>
<div className="flex-1 overflow-y-auto pr-3 space-y-6">
<div className="text-xs text-gray-500 leading-relaxed">
Petitions amend protocol parameters via the type-safe governance DSL.
Lifecycle: <span className="text-cyan-400">SIGNATURES</span> (14d, 25% oracle_rep_active threshold)
<span className="text-blue-400">VOTING</span> (7d, 67% supermajority + 30% quorum)
<span className="text-amber-400">CHALLENGE</span> (48h constitutional challenge window)
<span className="text-green-400">EXECUTED</span>.
The DSL executor rejects unknown CONFIG keys, type mismatches, out-of-bounds
values, and IMMUTABLE_PRINCIPLES writes see the validation preview below.
</div>
<FilePetitionForm onFiled={() => void reload()} />
{error && (
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
<div className="flex items-center gap-2">
<AlertCircle size={12} />
<span className="font-bold">Failed to load petitions</span>
</div>
<div className="text-gray-400 mt-1 font-mono">{error}</div>
</div>
)}
{grouped && grouped.active.length > 0 && (
<div>
<div className="text-xs uppercase tracking-wider text-cyan-400 mb-2">
Active Petitions ({grouped.active.length})
</div>
<div className="space-y-2">
{grouped.active.map((p) => (
<PetitionRow key={p.petition_id} petition={p} now={now} onAction={() => void reload()} />
))}
</div>
</div>
)}
{grouped && grouped.passed.length > 0 && (
<div>
<div className="text-xs uppercase tracking-wider text-green-400 mb-2">
Passed Petitions ({grouped.passed.length})
</div>
<div className="space-y-2">
{grouped.passed.map((p) => (
<PetitionRow key={p.petition_id} petition={p} now={now} onAction={() => void reload()} />
))}
</div>
</div>
)}
{grouped && grouped.closed.length > 0 && (
<div>
<div className="text-xs uppercase tracking-wider text-gray-500 mb-2">
Closed (Failed / Voided) ({grouped.closed.length})
</div>
<div className="space-y-2">
{grouped.closed.map((p) => (
<PetitionRow key={p.petition_id} petition={p} now={now} onAction={() => void reload()} />
))}
</div>
</div>
)}
{grouped && petitions && petitions.length === 0 && !loading && (
<div className="border border-gray-800 bg-black/40 p-6 text-center">
<div className="text-gray-500 text-sm mb-1">No petitions on the chain yet.</div>
<div className="text-gray-600 text-xs">
File one with the Preview tool above to see the lifecycle in action.
</div>
</div>
)}
</div>
</div>
);
}
@@ -2,8 +2,10 @@
import React, { useEffect, useState } from 'react';
import { ChevronLeft, User, Eye, EyeOff, Wallet, Activity, ShieldCheck, AlertCircle } from 'lucide-react';
import QRCode from 'qrcode';
import { API_BASE } from '@/lib/api';
import { exportWormholeDmInvite } from '@/mesh/wormholeIdentityClient';
interface ProfileViewProps {
onBack: () => void;
@@ -49,6 +51,11 @@ export default function ProfileView({ onBack, persona, isCitizen, nodeId, public
const [showTransactions, setShowTransactions] = useState(false);
const [reputation, setReputation] = useState<ReputationSummary>(EMPTY_REPUTATION);
const [oracleProfile, setOracleProfile] = useState<OracleProfileSummary>(EMPTY_ORACLE_PROFILE);
const [dmInviteBusy, setDmInviteBusy] = useState(false);
const [dmInviteBlob, setDmInviteBlob] = useState('');
const [dmInviteQrSrc, setDmInviteQrSrc] = useState('');
const [dmInviteFingerprint, setDmInviteFingerprint] = useState('');
const [dmInviteStatus, setDmInviteStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
useEffect(() => {
let active = true;
@@ -118,6 +125,40 @@ export default function ProfileView({ onBack, persona, isCitizen, nodeId, public
};
}, [nodeId]);
useEffect(() => {
let active = true;
if (!dmInviteBlob) {
setDmInviteQrSrc('');
return () => {
active = false;
};
}
void QRCode.toDataURL(dmInviteBlob, {
errorCorrectionLevel: 'M',
margin: 1,
width: 320,
color: {
dark: '#34d399',
light: '#05080d',
},
})
.then((dataUrl) => {
if (active) {
setDmInviteQrSrc(dataUrl);
}
})
.catch(() => {
if (active) {
setDmInviteQrSrc('');
}
});
return () => {
active = false;
};
}, [dmInviteBlob]);
const displayNodeId = nodeId?.trim() || 'NOT PROVISIONED';
const displayPersona = persona?.trim() || 'unassigned';
const creditsReference = publicKey?.trim() || 'Not provisioned';
@@ -141,6 +182,45 @@ export default function ProfileView({ onBack, persona, isCitizen, nodeId, public
const oracleRepLocked = oracleProfile.oracle_rep_locked;
const oracleProgress = oracleRepTotal > 0 ? Math.max(0, Math.min(100, (oracleRep / oracleRepTotal) * 100)) : 0;
const handleGenerateDmInvite = async () => {
setDmInviteBusy(true);
setDmInviteStatus(null);
try {
const exported = await exportWormholeDmInvite();
setDmInviteBlob(JSON.stringify(exported, null, 2));
setDmInviteFingerprint(String(exported.trust_fingerprint || ''));
setDmInviteStatus({
type: 'ok',
text: 'Signed DM invite generated. Share it only over a trusted out-of-band channel.',
});
} catch (error) {
setDmInviteStatus({
type: 'err',
text: error instanceof Error ? error.message : 'dm_invite_export_failed',
});
} finally {
setDmInviteBusy(false);
}
};
const handleCopyDmInvite = async () => {
if (!dmInviteBlob || !navigator?.clipboard?.writeText) {
return;
}
try {
await navigator.clipboard.writeText(dmInviteBlob);
setDmInviteStatus({
type: 'ok',
text: 'Signed DM invite copied to clipboard.',
});
} catch (error) {
setDmInviteStatus({
type: 'err',
text: error instanceof Error ? error.message : 'clipboard_write_failed',
});
}
};
return (
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
@@ -325,6 +405,76 @@ export default function ProfileView({ onBack, persona, isCitizen, nodeId, public
</div>
</div>
<div className="border border-gray-800 bg-gray-900/20 p-4">
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
<ShieldCheck size={16} className="mr-2" /> FIRST-CONTACT BOOTSTRAP
</h2>
<div className="space-y-4">
<p className="text-sm text-gray-400 leading-[1.7]">
Export a signed DM invite for trusted out-of-band exchange. This pins first contact to
your messaging identity instead of plain first-sight TOFU. It does not link wallet,
reputation, or other personas.
</p>
<div className="flex flex-wrap gap-3">
<button
onClick={() => void handleGenerateDmInvite()}
disabled={dmInviteBusy}
className="px-4 py-2 border border-cyan-500/40 bg-cyan-950/20 text-cyan-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
>
{dmInviteBusy ? 'Generating...' : 'Generate Signed DM Invite'}
</button>
<button
onClick={() => void handleCopyDmInvite()}
disabled={!dmInviteBlob}
className="px-4 py-2 border border-emerald-500/40 bg-emerald-950/20 text-emerald-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
>
Copy Invite
</button>
</div>
{dmInviteFingerprint && (
<div className="text-sm text-emerald-300 font-mono">
Trust fingerprint: {dmInviteFingerprint}
</div>
)}
{dmInviteStatus && (
<div
className={`px-3 py-2 border text-sm ${
dmInviteStatus.type === 'ok'
? 'border-emerald-500/30 bg-emerald-950/20 text-emerald-300'
: 'border-red-500/30 bg-red-950/20 text-red-300'
}`}
>
{dmInviteStatus.text}
</div>
)}
<textarea
value={dmInviteBlob}
readOnly
className="w-full min-h-[220px] bg-[#0a0a0a] border border-gray-800 px-4 py-3 text-sm text-gray-300 font-mono outline-none"
placeholder="Generate a signed DM invite to display the export blob here."
spellCheck={false}
/>
{dmInviteQrSrc && (
<div className="border border-emerald-500/20 bg-[#0a0a0a] p-4">
<div className="text-xs text-emerald-300 uppercase tracking-[0.18em] mb-3">
QR Invite
</div>
<div className="flex flex-col items-center gap-3">
<img
src={dmInviteQrSrc}
alt="Signed DM invite QR"
className="w-[320px] max-w-full border border-gray-800 bg-black p-3"
/>
<div className="text-xs text-gray-500 text-center leading-[1.65] max-w-[32rem]">
Scan this over a trusted out-of-band channel. The QR carries the same signed DM
invite shown above, including the trust fingerprint and signature envelope.
</div>
</div>
</div>
)}
</div>
</div>
<div className="border border-gray-800 bg-gray-900/20 p-4">
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
<Wallet size={16} className="mr-2" /> CREDITS LEDGER
@@ -0,0 +1,574 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { ChevronLeft, FileText, Scale, Loader, AlertCircle, CheckCircle2, ShieldOff } from 'lucide-react';
import {
buildDisputeOpenPayload,
buildDisputeStakePayload,
buildEvidenceSubmitPayload,
buildResolutionStakePayload,
fetchMarketState,
previewMarketResolution,
signAndAppend,
type AppendResult,
type DisputeSummary,
type MarketState,
type ResolutionPreview,
} from '@/mesh/infonetEconomyClient';
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
interface ResolutionViewProps {
marketId: string;
onBack: () => void;
}
const PHASE_STYLE: Record<string, { color: string; label: string }> = {
predicting: { color: 'text-cyan-400', label: 'PREDICTING' },
evidence: { color: 'text-amber-400', label: 'EVIDENCE WINDOW' },
resolving: { color: 'text-blue-400', label: 'RESOLVING' },
final: { color: 'text-green-400', label: 'FINAL' },
invalid: { color: 'text-red-400', label: 'INVALID' },
};
function DisputeRow({
dispute,
onAction,
}: {
dispute: DisputeSummary;
onAction: () => void;
}) {
const [side, setSide] = useState<'confirm' | 'reverse'>('reverse');
const [amount, setAmount] = useState('');
const [repType, setRepType] = useState<'oracle' | 'common'>('oracle');
const action = useSignAndAppend();
const busy = action.state === 'submitting';
const submit = useCallback(async () => {
const amt = Number(amount);
if (!Number.isFinite(amt) || amt <= 0) return;
const built = buildDisputeStakePayload(dispute.dispute_id, side, amt, repType);
const res = await action.submit(built.event_type, built.payload);
if (res.ok) {
setAmount('');
onAction();
}
}, [amount, side, repType, dispute.dispute_id, action, onAction]);
return (
<div className="border border-red-900/50 bg-red-900/10 p-2 text-xs">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-red-400 font-bold">DISPUTE</span>
{dispute.is_resolved ? (
<span
className={
dispute.resolved_outcome === 'reversed' ? 'text-red-400' : 'text-green-400'
}
>
{dispute.resolved_outcome?.toUpperCase()}
</span>
) : (
<span className="text-amber-400">PENDING</span>
)}
<span className="text-gray-500 font-mono truncate">
{dispute.dispute_id.slice(0, 12)}
</span>
</div>
<div className="text-gray-300">
Challenger: <span className="font-mono">{dispute.challenger_id.slice(0, 12)}</span>
{' — stake '}{dispute.challenger_stake.toFixed(2)}
</div>
<div className="text-gray-500 mt-1">
confirm: {dispute.confirm_stakes.length} stakes
reverse: {dispute.reverse_stakes.length} stakes
</div>
{!dispute.is_resolved && (
<div className="flex flex-wrap items-center gap-2 mt-2">
<select
value={side}
onChange={(e) => setSide(e.target.value as 'confirm' | 'reverse')}
title="Dispute stake side"
aria-label="Dispute stake side"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
>
<option value="confirm">CONFIRM</option>
<option value="reverse">REVERSE</option>
</select>
<input
type="number"
min="0"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="amount"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-24"
/>
<select
value={repType}
onChange={(e) => setRepType(e.target.value as 'oracle' | 'common')}
title="Reputation type to stake"
aria-label="Reputation type to stake"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
>
<option value="oracle">oracle</option>
<option value="common">common</option>
</select>
<button
type="button"
onClick={submit}
disabled={busy || !amount}
className="px-2 py-1 uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
>
{busy ? 'Staking…' : 'Stake'}
</button>
</div>
)}
{action.result && !action.result.ok && (
<div className="text-red-400 font-mono mt-2 break-all">
<AlertCircle size={10} className="inline mr-1" />
{action.result.reason}
</div>
)}
</div>
);
}
export default function ResolutionView({ marketId, onBack }: ResolutionViewProps) {
const [state, setState] = useState<MarketState | null>(null);
const [preview, setPreview] = useState<ResolutionPreview['preview'] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Resolution-stake form state.
const [stakeSide, setStakeSide] = useState<'yes' | 'no' | 'data_unavailable'>('yes');
const [stakeAmount, setStakeAmount] = useState('');
const [stakeRepType, setStakeRepType] = useState<'oracle' | 'common'>('oracle');
// Dispute-open form state.
const [disputeStake, setDisputeStake] = useState('');
const [disputeReason, setDisputeReason] = useState('');
// Evidence-submit form state (active during EVIDENCE phase).
const [evidenceOutcome, setEvidenceOutcome] = useState<'yes' | 'no'>('yes');
const [evidenceSourceDesc, setEvidenceSourceDesc] = useState('');
const [evidenceHashesInput, setEvidenceHashesInput] = useState('');
const [evidenceBond, setEvidenceBond] = useState('2');
const [evidenceSubmitting, setEvidenceSubmitting] = useState(false);
const [evidenceResult, setEvidenceResult] = useState<AppendResult | null>(null);
const stakeAction = useSignAndAppend();
const disputeAction = useSignAndAppend();
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [s, p] = await Promise.all([
fetchMarketState(marketId),
previewMarketResolution(marketId).catch(() => null),
]);
setState(s);
setPreview(p?.preview ?? null);
} catch (err) {
setError(err instanceof Error ? err.message : 'network error');
} finally {
setLoading(false);
}
}, [marketId]);
const submitStake = useCallback(async () => {
const amt = Number(stakeAmount);
if (!Number.isFinite(amt) || amt <= 0) return;
const built = buildResolutionStakePayload(marketId, stakeSide, amt, stakeRepType);
const res = await stakeAction.submit(built.event_type, built.payload);
if (res.ok) {
setStakeAmount('');
void reload();
}
}, [stakeAmount, stakeSide, stakeRepType, marketId, stakeAction, reload]);
const submitDispute = useCallback(async () => {
const stake = Number(disputeStake);
if (!Number.isFinite(stake) || stake <= 0) return;
if (!disputeReason.trim()) return;
const built = buildDisputeOpenPayload(marketId, stake, disputeReason.trim());
const res = await disputeAction.submit(built.event_type, built.payload);
if (res.ok) {
setDisputeStake('');
setDisputeReason('');
void reload();
}
}, [disputeStake, disputeReason, marketId, disputeAction, reload]);
const submitEvidence = useCallback(async () => {
if (!evidenceSourceDesc.trim()) return;
const hashes = evidenceHashesInput
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean);
if (hashes.length === 0) return;
const bond = Number(evidenceBond);
if (!Number.isFinite(bond) || bond < 0) return;
setEvidenceSubmitting(true);
setEvidenceResult(null);
try {
const built = await buildEvidenceSubmitPayload({
marketId,
claimedOutcome: evidenceOutcome,
evidenceHashes: hashes,
sourceDescription: evidenceSourceDesc.trim(),
bond,
});
const res = await signAndAppend({
event_type: built.event_type,
payload: built.payload,
});
setEvidenceResult(res);
if (res.ok) {
setEvidenceSourceDesc('');
setEvidenceHashesInput('');
void reload();
}
} catch (err) {
setEvidenceResult({
ok: false,
reason: err instanceof Error ? err.message : 'unknown_error',
});
} finally {
setEvidenceSubmitting(false);
}
}, [
evidenceOutcome, evidenceSourceDesc, evidenceHashesInput, evidenceBond,
marketId, reload,
]);
const phase = state ? PHASE_STYLE[state.status] : null;
const inEvidence = state?.status === 'evidence';
const inResolving = state?.status === 'resolving';
const isFinal = state?.status === 'final';
const hasActivePhase = inEvidence || inResolving || isFinal;
useEffect(() => {
void reload();
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 30_000);
return () => clearInterval(interval);
}, [reload, hasActivePhase]);
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
<button onClick={onBack} className="flex items-center text-cyan-400 hover:text-cyan-300 text-sm">
<ChevronLeft size={14} className="mr-1" /> BACK
</button>
<div className="text-sm text-cyan-400 font-bold uppercase tracking-widest flex items-center gap-2">
<Scale size={16} /> RESOLUTION {marketId}
</div>
<button onClick={() => void reload()} disabled={loading} className="text-xs text-gray-500 hover:text-cyan-400 disabled:opacity-30">
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
</button>
</div>
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
{error && (
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
<AlertCircle size={12} className="inline mr-1" />{error}
</div>
)}
{state && phase && (
<div className="border border-gray-800 bg-black/40 p-3">
<div className={`text-xs font-bold uppercase tracking-wider ${phase.color} mb-2`}>
PHASE: {phase.label}
{state.was_reversed && (
<span className="ml-2 text-red-400"> REVERSED BY DISPUTE</span>
)}
</div>
{state.snapshot && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
<div>
<div className="text-gray-500">Frozen Predictors</div>
<div className="text-white">{(state.snapshot.frozen_participant_count as number) ?? 0}</div>
</div>
<div>
<div className="text-gray-500">Frozen Total Stake</div>
<div className="text-white">{(state.snapshot.frozen_total_stake as number)?.toFixed?.(2) ?? '0.00'}</div>
</div>
<div>
<div className="text-gray-500">Excluded Predictors</div>
<div className="text-white">{state.excluded_predictor_ids.length}</div>
</div>
</div>
)}
</div>
)}
{state && state.evidence_bundles.length > 0 && (
<div>
<div className="text-xs uppercase tracking-wider text-amber-400 mb-2 flex items-center gap-1">
<FileText size={12} /> Evidence Bundles ({state.evidence_bundles.length})
</div>
<div className="space-y-2">
{state.evidence_bundles.map((b) => (
<div key={b.submission_hash} className="border border-gray-800 bg-black/40 p-2 text-xs">
<div className="flex items-center justify-between gap-2 mb-1">
<span className={`font-bold ${b.claimed_outcome === 'yes' ? 'text-green-400' : 'text-red-400'}`}>
{b.claimed_outcome.toUpperCase()}
</span>
{b.is_first_for_side && (
<span className="text-amber-400 text-xs"> FIRST-FOR-SIDE BONUS</span>
)}
<span className="text-gray-500 font-mono truncate">
{b.node_id.slice(0, 12)}
</span>
</div>
<div className="text-gray-300 mb-1">{b.source_description || '(no description)'}</div>
<div className="text-gray-500 font-mono">
bond: {b.bond} {b.evidence_hashes.length} hash{b.evidence_hashes.length === 1 ? '' : 'es'}
</div>
</div>
))}
</div>
</div>
)}
{state && state.disputes.length > 0 && (
<div>
<div className="text-xs uppercase tracking-wider text-red-400 mb-2 flex items-center gap-1">
<ShieldOff size={12} /> Disputes ({state.disputes.length})
</div>
<div className="space-y-2">
{state.disputes.map((d) => (
<DisputeRow
key={d.dispute_id}
dispute={d}
onAction={() => void reload()}
/>
))}
</div>
</div>
)}
{inEvidence && (
<div className="border border-amber-900/50 bg-amber-900/5 p-3">
<div className="text-xs uppercase tracking-wider text-amber-400 mb-2 flex items-center gap-1">
<FileText size={12} /> Submit Evidence
</div>
<div className="text-xs text-gray-500 mb-2">
Pay an evidence bond (
<span className="font-mono"> evidence_bond_cost</span>{' '}
oracle rep). The bond is returned if your claimed side wins;
forfeited otherwise. The first submitter per side gets a small
bonus from the losing pool when the market resolves on their
side. Hashes use the canonical content + submission scheme;
both are computed locally before signing.
</div>
<div className="space-y-2 text-xs">
<div className="flex flex-wrap items-center gap-2">
<select
value={evidenceOutcome}
onChange={(e) => setEvidenceOutcome(e.target.value as 'yes' | 'no')}
title="Claimed outcome"
aria-label="Claimed outcome"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
>
<option value="yes">YES</option>
<option value="no">NO</option>
</select>
<input
type="number"
min="0"
step="0.1"
value={evidenceBond}
onChange={(e) => setEvidenceBond(e.target.value)}
placeholder="bond"
title="Bond amount in oracle rep"
aria-label="Bond amount"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-24"
/>
<button
type="button"
onClick={submitEvidence}
disabled={
evidenceSubmitting ||
!evidenceSourceDesc.trim() ||
!evidenceHashesInput.trim()
}
className="px-3 py-1 uppercase tracking-wider border border-amber-700/50 bg-amber-900/20 text-amber-400 hover:bg-amber-900/40 disabled:opacity-30"
>
{evidenceSubmitting ? 'Submitting…' : 'Submit Evidence'}
</button>
</div>
<input
type="text"
value={evidenceSourceDesc}
onChange={(e) => setEvidenceSourceDesc(e.target.value)}
placeholder="source description (what + where)"
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
/>
<input
type="text"
value={evidenceHashesInput}
onChange={(e) => setEvidenceHashesInput(e.target.value)}
placeholder="evidence hashes (comma- or space-separated; ipfs://… or sha256:…)"
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
/>
</div>
{evidenceResult && !evidenceResult.ok && (
<div className="text-xs text-red-400 font-mono mt-2 break-all">
<AlertCircle size={10} className="inline mr-1" />
{evidenceResult.reason}
</div>
)}
{evidenceResult && evidenceResult.ok && (
<div className="text-xs text-green-400 font-mono mt-2 break-all">
<CheckCircle2 size={10} className="inline mr-1" />
evidence submitted event_id {String(evidenceResult.event.event_id).slice(0, 16)}
</div>
)}
</div>
)}
{inResolving && (
<div className="border border-blue-900/50 bg-blue-900/5 p-3">
<div className="text-xs uppercase tracking-wider text-blue-400 mb-2 flex items-center gap-1">
<Scale size={12} /> Stake on Resolution
</div>
<div className="text-xs text-gray-500 mb-2">
Pick a side and stake oracle (or common) rep. 75% of oracle stake on
one side reaches supermajority. <span className="text-amber-400">data_unavailable</span>{' '}
triggers phantom-evidence slashing if it crosses 33%.
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<select
value={stakeSide}
onChange={(e) => setStakeSide(e.target.value as 'yes' | 'no' | 'data_unavailable')}
title="Resolution stake side"
aria-label="Resolution stake side"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
>
<option value="yes">YES</option>
<option value="no">NO</option>
<option value="data_unavailable">DATA_UNAVAILABLE</option>
</select>
<input
type="number"
min="0"
step="0.01"
value={stakeAmount}
onChange={(e) => setStakeAmount(e.target.value)}
placeholder="amount"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-32"
/>
<select
value={stakeRepType}
onChange={(e) => setStakeRepType(e.target.value as 'oracle' | 'common')}
title="Reputation type to stake"
aria-label="Reputation type to stake"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
>
<option value="oracle">oracle rep</option>
<option value="common">common rep</option>
</select>
<button
type="button"
onClick={submitStake}
disabled={stakeAction.state === 'submitting' || !stakeAmount}
className="px-3 py-1 uppercase tracking-wider border border-blue-700/50 bg-blue-900/20 text-blue-400 hover:bg-blue-900/40 disabled:opacity-30"
>
{stakeAction.state === 'submitting' ? 'Staking…' : 'Stake'}
</button>
</div>
{stakeAction.result && !stakeAction.result.ok && (
<div className="text-xs text-red-400 font-mono mt-2 break-all">
<AlertCircle size={10} className="inline mr-1" />
{stakeAction.result.reason}
</div>
)}
</div>
)}
{isFinal && (
<div className="border border-red-900/50 bg-red-900/5 p-3">
<div className="text-xs uppercase tracking-wider text-red-400 mb-2 flex items-center gap-1">
<ShieldOff size={12} /> Open a Dispute
</div>
<div className="text-xs text-gray-500 mb-2">
Bounded reversal: a successful dispute flips the effective outcome of
THIS market only never cascades to other markets. Oracle-rep simple
majority decides; common rep can also be staked but doesn&apos;t decide
the outcome.
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<input
type="number"
min="0"
step="0.01"
value={disputeStake}
onChange={(e) => setDisputeStake(e.target.value)}
placeholder="challenger stake"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-32"
/>
<input
type="text"
value={disputeReason}
onChange={(e) => setDisputeReason(e.target.value)}
placeholder="reason (max 2000 chars)"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono flex-1 min-w-[200px]"
maxLength={2000}
/>
<button
type="button"
onClick={submitDispute}
disabled={
disputeAction.state === 'submitting' ||
!disputeStake || !disputeReason.trim()
}
className="px-3 py-1 uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
>
{disputeAction.state === 'submitting' ? 'Opening…' : 'Open Dispute'}
</button>
</div>
{disputeAction.result && !disputeAction.result.ok && (
<div className="text-xs text-red-400 font-mono mt-2 break-all">
<AlertCircle size={10} className="inline mr-1" />
{disputeAction.result.reason}
</div>
)}
</div>
)}
{preview && (
<div className="border border-cyan-900/50 bg-cyan-900/5 p-3">
<div className="text-xs uppercase tracking-wider text-cyan-400 mb-2 flex items-center gap-1">
<CheckCircle2 size={12} /> Resolution Preview (if closed now)
</div>
<div className="text-sm mb-2">
Outcome: <span className={
preview.outcome === 'yes' ? 'text-green-400 font-bold' :
preview.outcome === 'no' ? 'text-red-400 font-bold' :
'text-gray-400 font-bold'
}>{preview.outcome.toUpperCase()}</span>
<span className="text-gray-500 ml-2 font-mono">({preview.reason})</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-gray-400">
<div>
Winners: {preview.stake_winnings.length} stake winnings,
{' '}{preview.bond_returns.length} bond returns
</div>
<div>
Forfeited: {preview.bond_forfeits.length} bonds
{' '}Burned: {preview.burned_amount.toFixed(2)}
</div>
</div>
{preview.first_submitter_bonuses.length > 0 && (
<div className="text-xs text-amber-400 mt-1">
First-submitter bonuses: {preview.first_submitter_bonuses.map(b => `${b.node_id.slice(0,8)}…(${b.amount.toFixed(2)})`).join(', ')}
</div>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,375 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { ChevronLeft, GitBranch, Server, AlertCircle, CheckCircle2, Loader } from 'lucide-react';
import {
buildUpgradeProposePayload,
buildUpgradeSignPayload,
buildUpgradeSignalReadyPayload,
buildUpgradeVotePayload,
fetchUpgrades,
freshLocalId,
type UpgradeProposalSummary,
} from '@/mesh/infonetEconomyClient';
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
interface UpgradeViewProps {
onBack: () => void;
}
const STATUS_STYLE: Record<string, { color: string; label: string }> = {
signatures: { color: 'text-cyan-400', label: 'COLLECTING SIGNATURES' },
voting: { color: 'text-blue-400', label: 'VOTING' },
challenge: { color: 'text-amber-400', label: 'CHALLENGE WINDOW' },
activation: { color: 'text-purple-400', label: 'AWAITING HEAVY-NODE READINESS' },
activated: { color: 'text-green-500', label: 'ACTIVATED' },
failed_signatures: { color: 'text-red-400', label: 'FAILED — SIGNATURES' },
failed_vote: { color: 'text-red-400', label: 'FAILED — VOTE' },
voided_challenge: { color: 'text-red-500', label: 'VOIDED BY CHALLENGE' },
failed_activation: { color: 'text-red-400', label: 'FAILED — ACTIVATION' },
not_found: { color: 'text-gray-500', label: 'NOT FOUND' },
};
function UpgradeRow({
proposal,
onAction,
}: {
proposal: UpgradeProposalSummary;
onAction: () => void;
}) {
const style = STATUS_STYLE[proposal.status] ?? STATUS_STYLE.not_found;
const totalVotes = proposal.votes_for_weight + proposal.votes_against_weight;
const yesPct = totalVotes > 0 ? (proposal.votes_for_weight / totalVotes) * 100 : 0;
const readinessPct = (proposal.readiness_fraction || 0) * 100;
const { state, result, submit } = useSignAndAppend();
const busy = state === 'submitting';
const sign = useCallback(async () => {
const built = buildUpgradeSignPayload(proposal.proposal_id);
const res = await submit(built.event_type, built.payload);
if (res.ok) onAction();
}, [proposal.proposal_id, submit, onAction]);
const voteFor = useCallback(async () => {
const built = buildUpgradeVotePayload(proposal.proposal_id, 'for');
const res = await submit(built.event_type, built.payload);
if (res.ok) onAction();
}, [proposal.proposal_id, submit, onAction]);
const voteAgainst = useCallback(async () => {
const built = buildUpgradeVotePayload(proposal.proposal_id, 'against');
const res = await submit(built.event_type, built.payload);
if (res.ok) onAction();
}, [proposal.proposal_id, submit, onAction]);
const signalReady = useCallback(async () => {
const built = buildUpgradeSignalReadyPayload(
proposal.proposal_id,
proposal.release_hash,
);
const res = await submit(built.event_type, built.payload);
if (res.ok) onAction();
}, [proposal.proposal_id, proposal.release_hash, submit, onAction]);
return (
<div className="border border-gray-800 bg-black/40 p-3">
<div className="flex items-center justify-between gap-3 mb-2">
<div className="flex items-center gap-2">
<GitBranch size={14} className={style.color} />
<span className={`text-xs font-bold uppercase tracking-wider ${style.color}`}>
{style.label}
</span>
</div>
<span className="text-xs text-gray-500 font-mono">
v{proposal.target_protocol_version}
</span>
</div>
<div className="text-xs text-gray-400 mb-2 font-mono break-all">
release_hash: {proposal.release_hash.slice(0, 32)}
</div>
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
<div>
<div className="text-gray-500">Proposer</div>
<div className="text-gray-300 font-mono truncate">
{proposal.proposer_id.slice(0, 16)}
</div>
</div>
<div>
<div className="text-gray-500">Filed</div>
<div className="text-gray-300">
{proposal.filed_at ? new Date(proposal.filed_at * 1000).toLocaleDateString() : '—'}
</div>
</div>
</div>
{(proposal.status === 'voting' || proposal.status === 'challenge'
|| proposal.status === 'activation' || proposal.status === 'activated') && (
<>
<div className="text-xs text-gray-500 mb-1">
Vote: {proposal.votes_for_weight.toFixed(1)} for / {proposal.votes_against_weight.toFixed(1)} against
<span className="text-gray-600 ml-2">(80% supermajority required)</span>
</div>
<div className="h-1 bg-gray-800 mb-2 overflow-hidden flex">
<div className="h-full bg-green-500" style={{ width: `${yesPct}%` }} />
<div className="h-full bg-red-500" style={{ width: `${100 - yesPct}%` }} />
</div>
</>
)}
{(proposal.status === 'activation' || proposal.status === 'activated') && (
<>
<div className="text-xs text-purple-400 mb-1 flex items-center gap-1">
<Server size={11} />
Heavy-Node readiness: {readinessPct.toFixed(1)}%
<span className="text-gray-500 ml-2">(67% required for activation)</span>
{proposal.readiness_threshold_met && (
<span className="text-green-400 ml-2"> THRESHOLD MET</span>
)}
</div>
<div className="h-1 bg-gray-800 overflow-hidden">
<div
className="h-full bg-purple-500 transition-all"
style={{ width: `${Math.min(100, readinessPct)}%` }}
/>
</div>
</>
)}
<div className="flex flex-wrap gap-2 mt-3">
{proposal.status === 'signatures' && (
<button
type="button"
onClick={sign}
disabled={busy}
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-purple-700/50 bg-purple-900/20 text-purple-400 hover:bg-purple-900/40 disabled:opacity-30"
>
{busy ? 'Signing…' : 'Sign'}
</button>
)}
{proposal.status === 'voting' && (
<>
<button
type="button"
onClick={voteFor}
disabled={busy}
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-green-700/50 bg-green-900/20 text-green-400 hover:bg-green-900/40 disabled:opacity-30"
>
{busy ? '…' : 'Vote FOR'}
</button>
<button
type="button"
onClick={voteAgainst}
disabled={busy}
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
>
{busy ? '…' : 'Vote AGAINST'}
</button>
</>
)}
{proposal.status === 'activation' && (
<button
type="button"
onClick={signalReady}
disabled={busy}
title="Signal that this Heavy Node has installed and verified the new release"
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-purple-700/50 bg-purple-900/20 text-purple-400 hover:bg-purple-900/40 disabled:opacity-30"
>
{busy ? '…' : 'Signal Ready'}
</button>
)}
</div>
{result && !result.ok && (
<div className="text-xs text-red-400 font-mono mt-2 break-all">
<AlertCircle size={10} className="inline mr-1" />
{result.reason}
</div>
)}
</div>
);
}
function ProposeUpgradePanel({ onAction }: { onAction: () => void }) {
const [releaseHash, setReleaseHash] = useState('');
const [releaseDescription, setReleaseDescription] = useState('');
const [targetProtocolVersion, setTargetProtocolVersion] = useState('');
const { state, result, submit } = useSignAndAppend();
const busy = state === 'submitting';
const propose = useCallback(async () => {
const trimmedHash = releaseHash.trim().toLowerCase();
const trimmedDesc = releaseDescription.trim();
const trimmedVersion = targetProtocolVersion.trim();
if (trimmedHash.length !== 64 || !/^[0-9a-f]{64}$/.test(trimmedHash)) return;
if (!trimmedDesc) return;
if (!trimmedVersion) return;
const built = buildUpgradeProposePayload({
proposalId: freshLocalId('upg'),
releaseHash: trimmedHash,
releaseDescription: trimmedDesc,
targetProtocolVersion: trimmedVersion,
});
const res = await submit(built.event_type, built.payload);
if (res.ok) {
setReleaseHash('');
setReleaseDescription('');
setTargetProtocolVersion('');
onAction();
}
}, [releaseHash, releaseDescription, targetProtocolVersion, submit, onAction]);
return (
<div className="border border-purple-900/50 bg-purple-900/5 p-3">
<div className="text-xs uppercase tracking-wider text-purple-400 font-bold mb-2">
File Upgrade Proposal
</div>
<div className="text-xs text-gray-500 mb-3 leading-relaxed">
Filing requires <span className="text-purple-400">upgrade_filing_cost</span> common rep and a
SHA-256 hash of the verified release artifact. After filing, the proposal collects
signatures, then enters voting (80% supermajority / 40% quorum), then the challenge window,
then awaits 67% Heavy-Node readiness signal before activation.
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 mb-2">
<input
type="text"
value={releaseHash}
onChange={(e) => setReleaseHash(e.target.value)}
placeholder="release_hash (64 hex chars)"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono text-xs col-span-1 md:col-span-2"
/>
<input
type="number"
min="1"
step="1"
value={targetProtocolVersion}
onChange={(e) => setTargetProtocolVersion(e.target.value)}
placeholder="target protocol_version"
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono text-xs"
/>
</div>
<textarea
value={releaseDescription}
onChange={(e) => setReleaseDescription(e.target.value)}
placeholder="release_description — what changes / event types / formulas does this introduce?"
rows={2}
className="bg-black/60 border border-gray-700 px-2 py-1 text-white text-xs w-full mb-2"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={propose}
disabled={busy}
className="px-3 py-1 text-xs uppercase tracking-wider border border-purple-700/50 bg-purple-900/20 text-purple-400 hover:bg-purple-900/40 disabled:opacity-30"
>
{busy ? 'Filing…' : 'Propose Upgrade'}
</button>
{result && result.ok && (
<span className="text-xs text-green-400 flex items-center gap-1">
<CheckCircle2 size={11} /> Filed
</span>
)}
</div>
{result && !result.ok && (
<div className="text-xs text-red-400 font-mono mt-2 break-all">
<AlertCircle size={10} className="inline mr-1" />
{result.reason}
</div>
)}
</div>
);
}
export default function UpgradeView({ onBack }: UpgradeViewProps) {
const [upgrades, setUpgrades] = useState<UpgradeProposalSummary[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchUpgrades();
setUpgrades(data.upgrades);
} catch (err) {
setError(err instanceof Error ? err.message : 'network error');
} finally {
setLoading(false);
}
}, []);
const hasActivePhase = (upgrades || []).some((u) =>
u.status === 'signatures' || u.status === 'voting' ||
u.status === 'challenge' || u.status === 'activation',
);
useEffect(() => {
void reload();
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 60_000);
return () => clearInterval(interval);
}, [reload, hasActivePhase]);
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
<button
onClick={onBack}
className="flex items-center text-cyan-400 hover:text-cyan-300 transition-colors text-sm"
>
<ChevronLeft size={14} className="mr-1" />
BACK
</button>
<div className="text-sm text-purple-400 font-bold uppercase tracking-widest flex items-center gap-2">
<GitBranch size={16} />
UPGRADE-HASH GOVERNANCE
</div>
<button
onClick={() => void reload()}
disabled={loading}
className="text-xs text-gray-500 hover:text-purple-400 disabled:opacity-30"
>
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
</button>
</div>
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
<div className="text-xs text-gray-500 leading-relaxed">
Protocol upgrades that need new logic / new event types / new formulas
can&apos;t be expressed as parameter changes they use upgrade-hash
governance. The network votes on a software release&apos;s SHA-256 hash;
Heavy Nodes that have downloaded and verified the release emit
<span className="text-purple-400"> upgrade_signal_ready</span>. Once 67%
of Heavy Nodes have signaled, the upgrade activates and protocol_version
increments. Higher thresholds than param petitions: <span className="text-green-400">80% supermajority</span>,
<span className="text-blue-400"> 40% quorum</span>,
<span className="text-purple-400"> 67% Heavy-Node activation</span>.
</div>
<ProposeUpgradePanel onAction={() => void reload()} />
{error && (
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
<AlertCircle size={12} className="inline mr-1" />
<span className="font-bold">Failed to load:</span>
<span className="text-gray-400 ml-2 font-mono">{error}</span>
</div>
)}
{upgrades && upgrades.length === 0 && !loading && (
<div className="border border-gray-800 bg-black/40 p-6 text-center">
<div className="text-gray-500 text-sm">No upgrade proposals on chain.</div>
<div className="text-gray-600 text-xs mt-1">
Filing requires <span className="text-purple-400">upgrade_filing_cost</span> common rep
and a SHA-256 release hash.
</div>
</div>
)}
{upgrades?.map((u) => (
<UpgradeRow key={u.proposal_id} proposal={u} onAction={() => void reload()} />
))}
</div>
</div>
);
}
@@ -2,17 +2,7 @@
import React, { useState, useEffect } from 'react';
const LOCATIONS = [
{ name: 'Night City', tz: 'America/Los_Angeles', tempC: 18 },
{ name: 'Tokyo', tz: 'Asia/Tokyo', tempC: 22 },
{ name: 'New York', tz: 'America/New_York', tempC: 25 },
{ name: 'London', tz: 'Europe/London', tempC: 12 },
{ name: 'Neo Seoul', tz: 'Asia/Seoul', tempC: 19 },
];
export default function WeatherWidget() {
const [locIdx, setLocIdx] = useState(0);
const [isCelsius, setIsCelsius] = useState(false);
const [time, setTime] = useState(new Date());
useEffect(() => {
@@ -20,32 +10,19 @@ export default function WeatherWidget() {
return () => clearInterval(timer);
}, []);
const loc = LOCATIONS[locIdx];
const temp = isCelsius ? loc.tempC : Math.round(loc.tempC * 9/5 + 32);
const tempUnit = isCelsius ? 'C' : 'F';
const timeString = time.toLocaleTimeString('en-US', { timeZone: loc.tz, hour12: false, hour: '2-digit', minute: '2-digit' });
const dateString = time.toLocaleDateString('en-US', { timeZone: loc.tz, month: 'short', day: 'numeric' });
const timeString = time.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
});
const dateString = time.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
return (
<div className="flex items-center gap-2 text-sm md:text-xs text-gray-400 border border-gray-800 bg-gray-900/30 px-2 py-1 shrink-0 font-mono tracking-widest uppercase whitespace-nowrap">
<span>{dateString} {timeString}</span>
<span className="text-gray-700">|</span>
<span
className="cursor-pointer hover:text-white transition-colors"
onClick={() => setLocIdx((i) => (i + 1) % LOCATIONS.length)}
title="Change Location & Timezone"
>
{loc.name}
</span>
<span className="text-gray-700">|</span>
<span
className="cursor-pointer hover:text-white transition-colors"
onClick={() => setIsCelsius(!isCelsius)}
title="Toggle C / F"
>
{temp}&deg;{tempUnit}
</span>
</div>
);
}
@@ -9,9 +9,15 @@ interface InfonetTerminalProps {
isOpen: boolean;
onClose: () => void;
onOpenLiveGate?: (gate: string) => void;
onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void;
}
export default function InfonetTerminal({ isOpen, onClose, onOpenLiveGate }: InfonetTerminalProps) {
export default function InfonetTerminal({
isOpen,
onClose,
onOpenLiveGate,
onOpenDeadDrop,
}: InfonetTerminalProps) {
/* Close on Escape */
useEffect(() => {
if (!isOpen) return;
@@ -68,7 +74,12 @@ export default function InfonetTerminal({ isOpen, onClose, onOpenLiveGate }: Inf
{/* Shell content — fills remaining space, scrolls internally */}
<div className="flex-1 overflow-hidden">
<InfonetShell isOpen={isOpen} onClose={onClose} onOpenLiveGate={onOpenLiveGate} />
<InfonetShell
isOpen={isOpen}
onClose={onClose}
onOpenLiveGate={onOpenLiveGate}
onOpenDeadDrop={onOpenDeadDrop}
/>
</div>
</motion.div>
</motion.div>