diff --git a/app/api/stellar/health/history/route.ts b/app/api/stellar/health/history/route.ts
new file mode 100644
index 0000000..7a996b6
--- /dev/null
+++ b/app/api/stellar/health/history/route.ts
@@ -0,0 +1,29 @@
+/**
+ * GET /api/stellar/health/history
+ *
+ * Returns the outage history log for audit/debugging purposes.
+ */
+
+import { NextResponse } from "next/server";
+import { getOutageLog } from "@/lib/stellar/health-monitor";
+
+export async function GET() {
+ const log = getOutageLog();
+
+ return NextResponse.json({
+ outages: log.map((event) => ({
+ id: event.id,
+ startedAt: event.startedAt,
+ endedAt: event.endedAt,
+ durationMs: event.durationMs,
+ status: event.status,
+ error: event.error,
+ recovered: event.recovered,
+ })),
+ summary: {
+ total: log.length,
+ recovered: log.filter((o) => o.recovered).length,
+ active: log.filter((o) => !o.recovered).length,
+ },
+ });
+}
\ No newline at end of file
diff --git a/app/api/stellar/health/route.test.ts b/app/api/stellar/health/route.test.ts
new file mode 100644
index 0000000..dbca834
--- /dev/null
+++ b/app/api/stellar/health/route.test.ts
@@ -0,0 +1,38 @@
+/**
+ * API route tests for /api/stellar/health
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { GET } from "./route";
+
+describe("GET /api/stellar/health", () => {
+ beforeEach(() => {
+ // Reset environment
+ delete process.env.STELLAR_HORIZON_URL;
+ delete process.env.STELLAR_NETWORK;
+ delete process.env.STELLAR_SOURCE_SECRET;
+ });
+
+ it("should return JSON health status", async () => {
+ const request = new Request("http://localhost/api/stellar/health");
+ const response = await GET(request);
+ const data = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(data).toHaveProperty("status");
+ expect(data).toHaveProperty("latencyMs");
+ expect(data).toHaveProperty("checkedAt");
+ expect(data).toHaveProperty("outage");
+ expect(data).toHaveProperty("history");
+ });
+
+ it("should return SSE stream when Accept header is text/event-stream", async () => {
+ const request = new Request("http://localhost/api/stellar/health", {
+ headers: { Accept: "text/event-stream" },
+ });
+ const response = await GET(request);
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toBe("text/event-stream");
+ });
+});
\ No newline at end of file
diff --git a/app/api/stellar/health/route.ts b/app/api/stellar/health/route.ts
new file mode 100644
index 0000000..beef232
--- /dev/null
+++ b/app/api/stellar/health/route.ts
@@ -0,0 +1,122 @@
+/**
+ * GET /api/stellar/health
+ *
+ * Returns the current Stellar network health status.
+ * Supports Server-Sent Events (SSE) for real-time updates when
+ * the client requests streaming via Accept: text/event-stream.
+ */
+
+import { NextResponse } from "next/server";
+import {
+ checkHealth,
+ subscribe,
+ getCurrentStatus,
+ getNetworkStatus,
+ getOutageLog,
+ startMonitoring,
+ getActiveOutage,
+} from "@/lib/stellar/health-monitor";
+
+// Ensure monitoring is running on the server
+startMonitoring(30000);
+
+export async function GET(request: Request) {
+ const accept = request.headers.get("accept") || "";
+
+ // SSE streaming for real-time updates
+ if (accept.includes("text/event-stream")) {
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ start(controller) {
+ const send = (data: any) => {
+ try {
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
+ );
+ } catch {
+ // Stream closed
+ }
+ };
+
+ // Send initial state
+ const current = getCurrentStatus();
+ if (current) {
+ send({
+ type: "health_update",
+ status: current.status,
+ latencyMs: current.latencyMs,
+ ledger: current.ledger,
+ protocolVersion: current.protocolVersion,
+ network: current.network,
+ checkedAt: current.checkedAt,
+ outage: getActiveOutage(),
+ });
+ }
+
+ // Subscribe to updates
+ const unsubscribe = subscribe((result) => {
+ send({
+ type: "health_update",
+ status: result.status,
+ latencyMs: result.latencyMs,
+ ledger: result.ledger,
+ protocolVersion: result.protocolVersion,
+ network: result.network,
+ checkedAt: result.checkedAt,
+ outage: getActiveOutage(),
+ });
+ });
+
+ // Keep alive
+ const keepAlive = setInterval(() => {
+ try {
+ controller.enqueue(encoder.encode(":keepalive\n\n"));
+ } catch {
+ clearInterval(keepAlive);
+ }
+ }, 15000);
+
+ // Cleanup on close
+ request.signal.addEventListener("abort", () => {
+ unsubscribe();
+ clearInterval(keepAlive);
+ });
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive",
+ },
+ });
+ }
+
+ // Standard JSON response
+ const result = getCurrentStatus() || (await checkHealth());
+ const activeOutage = getActiveOutage();
+
+ return NextResponse.json({
+ status: result.status,
+ latencyMs: result.latencyMs,
+ ledger: result.ledger,
+ protocolVersion: result.protocolVersion,
+ horizonUrl: result.horizonUrl,
+ network: result.network,
+ checkedAt: result.checkedAt,
+ error: result.error,
+ outage: activeOutage
+ ? {
+ id: activeOutage.id,
+ startedAt: activeOutage.startedAt,
+ status: activeOutage.status,
+ error: activeOutage.error,
+ }
+ : null,
+ history: {
+ totalOutages: getOutageLog().length,
+ lastOutage: getOutageLog().slice(-1)[0] || null,
+ },
+ });
+}
\ No newline at end of file
diff --git a/app/chat/page.tsx b/app/chat/page.tsx
index 6c26df3..2a58a76 100644
--- a/app/chat/page.tsx
+++ b/app/chat/page.tsx
@@ -38,6 +38,7 @@ import {
ScrollText,
Users,
} from "lucide-react";
+import { StellarNetworkStatus } from "@/components/stellar-network-status";
type ChatPreview = {
id: string;
@@ -488,6 +489,8 @@ export default function ChatPage() {
+
+
diff --git a/components/stellar-network-status.tsx b/components/stellar-network-status.tsx
new file mode 100644
index 0000000..813c91f
--- /dev/null
+++ b/components/stellar-network-status.tsx
@@ -0,0 +1,319 @@
+/**
+ * StellarNetworkStatus
+ *
+ * A responsive UI component that displays the current Stellar network
+ * health status. Auto-refreshes via SSE for real-time updates.
+ *
+ * Usage:
+ *
// Default inline badge
+ *
// Full banner for chat page
+ *
// Minimal dot indicator
+ */
+
+"use client";
+
+import React, { useEffect, useState, useCallback } from "react";
+import {
+ Activity,
+ AlertTriangle,
+ Wifi,
+ WifiOff,
+ RefreshCw,
+ Clock,
+ Database,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+
+type NetworkStatus = "healthy" | "degraded" | "unavailable";
+
+interface HealthData {
+ status: NetworkStatus;
+ latencyMs: number;
+ ledger: number;
+ protocolVersion: number;
+ network: string;
+ checkedAt: string;
+ error?: string;
+ outage?: {
+ id: string;
+ startedAt: string;
+ status: NetworkStatus;
+ error?: string;
+ } | null;
+}
+
+interface StellarNetworkStatusProps {
+ variant?: "badge" | "banner" | "compact";
+ className?: string;
+ showDetails?: boolean;
+}
+
+const STATUS_CONFIG: Record<
+ NetworkStatus,
+ {
+ label: string;
+ color: string;
+ bgColor: string;
+ borderColor: string;
+ icon: React.ReactNode;
+ pulseColor: string;
+ }
+> = {
+ healthy: {
+ label: "Stellar Network Healthy",
+ color: "text-emerald-500",
+ bgColor: "bg-emerald-500/10",
+ borderColor: "border-emerald-500/20",
+ icon:
,
+ pulseColor: "bg-emerald-500",
+ },
+ degraded: {
+ label: "Stellar Network Degraded",
+ color: "text-amber-500",
+ bgColor: "bg-amber-500/10",
+ borderColor: "border-amber-500/20",
+ icon:
,
+ pulseColor: "bg-amber-500",
+ },
+ unavailable: {
+ label: "Stellar Network Unavailable",
+ color: "text-red-500",
+ bgColor: "bg-red-500/10",
+ borderColor: "border-red-500/20",
+ icon:
,
+ pulseColor: "bg-red-500",
+ },
+};
+
+export function StellarNetworkStatus({
+ variant = "badge",
+ className,
+ showDetails = false,
+}: StellarNetworkStatusProps) {
+ const [health, setHealth] = useState
(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [lastUpdated, setLastUpdated] = useState(null);
+ const [retryCount, setRetryCount] = useState(0);
+
+ const fetchHealth = useCallback(async () => {
+ try {
+ const response = await fetch("/api/stellar/health");
+ if (!response.ok) throw new Error("Failed to fetch health status");
+ const data = await response.json();
+ setHealth(data);
+ setLastUpdated(new Date());
+ setRetryCount(0);
+ } catch (err) {
+ setHealth({
+ status: "unavailable",
+ latencyMs: 0,
+ ledger: 0,
+ protocolVersion: 0,
+ network: "unknown",
+ checkedAt: new Date().toISOString(),
+ error: "Unable to reach health monitor",
+ });
+ setRetryCount((c) => c + 1);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ // SSE for real-time updates
+ useEffect(() => {
+ fetchHealth();
+
+ const eventSource = new EventSource("/api/stellar/health");
+ eventSource.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ if (data.type === "health_update") {
+ setHealth(data);
+ setLastUpdated(new Date());
+ setRetryCount(0);
+ }
+ } catch {
+ // Ignore parse errors
+ }
+ };
+
+ eventSource.onerror = () => {
+ // Fallback to polling if SSE fails
+ eventSource.close();
+ const interval = setInterval(fetchHealth, 30000);
+ return () => clearInterval(interval);
+ };
+
+ return () => {
+ eventSource.close();
+ };
+ }, [fetchHealth]);
+
+ // Manual retry with exponential backoff
+ const handleRetry = useCallback(() => {
+ setIsLoading(true);
+ fetchHealth();
+ }, [fetchHealth]);
+
+ if (isLoading) {
+ return (
+
+
+ Checking network...
+
+ );
+ }
+
+ if (!health) return null;
+
+ const config = STATUS_CONFIG[health.status];
+
+ // Compact variant — just a colored dot with tooltip
+ if (variant === "compact") {
+ return (
+
+
+
+
+
+ {/* Tooltip */}
+
+ {config.label}
+ {health.latencyMs > 0 && ` • ${health.latencyMs}ms`}
+
+
+ );
+ }
+
+ // Banner variant — full width alert for chat page
+ if (variant === "banner") {
+ if (health.status === "healthy" && !showDetails) return null;
+
+ return (
+
+
+
+
+ {config.icon}
+
+
+
+ {config.label}
+
+ {health.error && (
+
+ {health.error}
+
+ )}
+
+
+
+
+ {health.status !== "healthy" && (
+
+ )}
+
+ {showDetails && health.status === "healthy" && (
+
+
+
+ {health.latencyMs}ms
+
+
+
+ Ledger #{health.ledger}
+
+
+ )}
+
+ {lastUpdated && (
+
+ {lastUpdated.toLocaleTimeString()}
+
+ )}
+
+
+
+ );
+ }
+
+ // Default badge variant
+ return (
+
+
+
+
+
+ {config.label}
+
+ {health.status === "healthy"
+ ? "Online"
+ : health.status === "degraded"
+ ? "Slow"
+ : "Offline"}
+
+ {showDetails && health.latencyMs > 0 && (
+
+ {health.latencyMs}ms
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/lib/stellar/health-monitor.test.ts b/lib/stellar/health-monitor.test.ts
new file mode 100644
index 0000000..06f3ba7
--- /dev/null
+++ b/lib/stellar/health-monitor.test.ts
@@ -0,0 +1,106 @@
+/**
+ * Unit tests for Stellar Network Health Monitor
+ *
+ * Tests connectivity checks, outage detection, status transitions,
+ * and recovery logging.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import {
+ checkHealth,
+ getCurrentStatus,
+ getNetworkStatus,
+ getOutageLog,
+ clearOutageLog,
+ subscribe,
+ startMonitoring,
+ stopMonitoring,
+} from "./health-monitor";
+
+describe("Stellar Health Monitor", () => {
+ beforeEach(() => {
+ clearOutageLog();
+ stopMonitoring();
+ delete process.env.STELLAR_HORIZON_URL;
+ delete process.env.STELLAR_NETWORK;
+ delete process.env.STELLAR_SOURCE_SECRET;
+ });
+
+ afterEach(() => {
+ stopMonitoring();
+ clearOutageLog();
+ });
+
+ it("should return unavailable when Stellar is not configured", async () => {
+ const result = await checkHealth();
+ expect(result.status).toBe("unavailable");
+ expect(result.error).toContain("not available");
+ });
+
+ it("should track status transitions and log outages", async () => {
+ // Simulate: healthy → unavailable → healthy
+ process.env.STELLAR_HORIZON_URL = "https://horizon-testnet.stellar.org";
+ process.env.STELLAR_NETWORK = "testnet";
+ process.env.STELLAR_SOURCE_SECRET = "SDUMMY";
+
+ const result1 = await checkHealth();
+ // Result depends on actual network; we verify structure
+ expect(["healthy", "degraded", "unavailable"]).toContain(result1.status);
+ expect(result1.checkedAt).toBeDefined();
+ expect(result1.latencyMs).toBeGreaterThanOrEqual(0);
+
+ const status = getNetworkStatus();
+ expect(status).toBe(result1.status);
+
+ const current = getCurrentStatus();
+ expect(current).not.toBeNull();
+ expect(current?.status).toBe(result1.status);
+ });
+
+ it("should notify subscribers on status change", async () => {
+ const listener = vi.fn();
+ const unsubscribe = subscribe(listener);
+
+ process.env.STELLAR_HORIZON_URL = "https://horizon-testnet.stellar.org";
+ process.env.STELLAR_NETWORK = "testnet";
+ process.env.STELLAR_SOURCE_SECRET = "SDUMMY";
+
+ await checkHealth();
+
+ expect(listener).toHaveBeenCalled();
+ expect(listener.mock.calls[0][0]).toHaveProperty("status");
+ expect(listener.mock.calls[0][0]).toHaveProperty("latencyMs");
+
+ unsubscribe();
+ });
+
+ it("should handle multiple subscribers", async () => {
+ const listener1 = vi.fn();
+ const listener2 = vi.fn();
+
+ const unsub1 = subscribe(listener1);
+ const unsub2 = subscribe(listener2);
+
+ process.env.STELLAR_HORIZON_URL = "https://horizon-testnet.stellar.org";
+ process.env.STELLAR_NETWORK = "testnet";
+ process.env.STELLAR_SOURCE_SECRET = "SDUMMY";
+
+ await checkHealth();
+
+ expect(listener1).toHaveBeenCalled();
+ expect(listener2).toHaveBeenCalled();
+
+ unsub1();
+ unsub2();
+ });
+
+ it("should start and stop monitoring without errors", () => {
+ expect(() => startMonitoring(1000)).not.toThrow();
+ expect(() => stopMonitoring()).not.toThrow();
+ });
+
+ it("should clear outage log", () => {
+ clearOutageLog();
+ expect(getOutageLog()).toHaveLength(0);
+ });
+});
\ No newline at end of file
diff --git a/lib/stellar/health-monitor.ts b/lib/stellar/health-monitor.ts
new file mode 100644
index 0000000..d6769ee
--- /dev/null
+++ b/lib/stellar/health-monitor.ts
@@ -0,0 +1,252 @@
+/**
+ * Stellar Network Health Monitor
+ *
+ * Monitors Horizon API connectivity and tracks network status
+ * with automatic polling, outage detection, and recovery logging.
+ */
+
+import { Horizon } from "@stellar/stellar-sdk";
+import { loadStellarConfig, isConfigured } from "@/lib/blockchain/stellar-config";
+import { logBlockchainOperation } from "@/lib/blockchain/logger";
+
+export type NetworkStatus = "healthy" | "degraded" | "unavailable";
+function generateEventId(): string {
+ return `outage_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+}
+
+export interface HealthCheckResult {
+ status: NetworkStatus;
+ latencyMs: number;
+ ledger: number;
+ protocolVersion: number;
+ horizonUrl: string;
+ network: string;
+ checkedAt: string;
+ error?: string;
+}
+
+export interface OutageEvent {
+ id: string;
+ startedAt: string;
+ endedAt?: string;
+ durationMs?: number;
+ status: NetworkStatus;
+ error?: string;
+ recovered: boolean;
+}
+
+const DEFAULT_POLL_INTERVAL_MS = 30000; // 30 seconds
+const HEALTHY_THRESHOLD_MS = 2000;
+const DEGRADED_THRESHOLD_MS = 5000;
+
+let currentStatus: NetworkStatus = "unavailable";
+let lastCheck: HealthCheckResult | null = null;
+let outageLog: OutageEvent[] = [];
+let activeOutage: OutageEvent | null = null;
+let pollTimer: NodeJS.Timeout | null = null;
+let listeners: Set<(result: HealthCheckResult) => void> = new Set();
+
+function logOutage(event: OutageEvent, action: "started" | "ended" | "updated"): void {
+ logBlockchainOperation(
+ action === "started" ? "error" : action === "ended" ? "info" : "warn",
+ `Network outage ${action}`,
+ {
+ outageId: event.id,
+ status: event.status,
+ durationMs: event.durationMs,
+ error: event.error
+ ? { type: "StellarNetworkError", message: event.error }
+ : undefined,
+ }
+ );
+}
+
+/**
+ * Performs a single health check against the Stellar Horizon API
+ */
+export async function checkHealth(): Promise {
+ const startTime = Date.now();
+ const config = loadStellarConfig();
+
+ if (!isConfigured() || !config) {
+ const result: HealthCheckResult = {
+ status: "unavailable",
+ latencyMs: 0,
+ ledger: 0,
+ protocolVersion: 0,
+ horizonUrl: process.env.STELLAR_HORIZON_URL || "unknown",
+ network: (process.env.STELLAR_NETWORK as string) || "unknown",
+ checkedAt: new Date().toISOString(),
+ error: "Stellar configuration not available",
+ };
+ updateStatus(result);
+ return result;
+ }
+
+ try {
+ const server = new Horizon.Server(config.horizonUrl);
+ const ledgerResponse = await server.ledgers().order("desc").limit(1).call();
+ const latestLedger = ledgerResponse.records[0];
+
+ const latencyMs = Date.now() - startTime;
+ let status: NetworkStatus = "healthy";
+ if (latencyMs > DEGRADED_THRESHOLD_MS) {
+ status = "unavailable";
+ } else if (latencyMs > HEALTHY_THRESHOLD_MS) {
+ status = "degraded";
+ }
+
+ const result: HealthCheckResult = {
+ status,
+ latencyMs,
+ ledger: latestLedger.sequence,
+ protocolVersion: latestLedger.protocol_version,
+ horizonUrl: config.horizonUrl,
+ network: config.network,
+ checkedAt: new Date().toISOString(),
+ };
+
+ updateStatus(result);
+ return result;
+ } catch (error: any) {
+ const result: HealthCheckResult = {
+ status: "unavailable",
+ latencyMs: Date.now() - startTime,
+ ledger: 0,
+ protocolVersion: 0,
+ horizonUrl: config.horizonUrl,
+ network: config.network,
+ checkedAt: new Date().toISOString(),
+ error: error.message || "Unknown error",
+ };
+
+ updateStatus(result);
+ return result;
+ }
+}
+
+/**
+ * Updates internal status and manages outage logging
+ */
+function updateStatus(result: HealthCheckResult): void {
+ const previousStatus = currentStatus;
+ currentStatus = result.status;
+ lastCheck = result;
+
+ // Notify listeners
+ listeners.forEach((cb) => {
+ try {
+ cb(result);
+ } catch (e) {
+ // Silently ignore listener errors
+ }
+ });
+
+ // Outage detection
+ if (result.status === "unavailable" && previousStatus !== "unavailable") {
+ // Outage started
+ activeOutage = {
+ id: generateEventId(),
+ startedAt: result.checkedAt,
+ status: result.status,
+ error: result.error,
+ recovered: false,
+ };
+ outageLog.push(activeOutage);
+ logOutage(activeOutage, "started");
+ } else if (result.status !== "unavailable" && previousStatus === "unavailable" && activeOutage) {
+ // Outage ended
+ const endedAt = new Date().toISOString();
+ const durationMs = new Date(endedAt).getTime() - new Date(activeOutage.startedAt).getTime();
+ activeOutage.endedAt = endedAt;
+ activeOutage.durationMs = durationMs;
+ activeOutage.recovered = true;
+ logOutage(activeOutage, "ended");
+ activeOutage = null;
+ }
+}
+
+/**
+ * Starts automatic health monitoring with polling
+ */
+export function startMonitoring(intervalMs = DEFAULT_POLL_INTERVAL_MS): void {
+ if (pollTimer) {
+ clearInterval(pollTimer);
+ }
+
+ // Immediate first check
+ checkHealth();
+
+ pollTimer = setInterval(() => {
+ checkHealth().catch((err) => {
+ console.error("[HealthMonitor] Polling error:", err);
+ });
+ }, intervalMs);
+
+ logBlockchainOperation("info", "Stellar health monitoring started", {
+ intervalMs,
+ horizonUrl: process.env.STELLAR_HORIZON_URL,
+ });
+}
+
+/**
+ * Stops automatic health monitoring
+ */
+export function stopMonitoring(): void {
+ if (pollTimer) {
+ clearInterval(pollTimer);
+ pollTimer = null;
+ }
+ logBlockchainOperation("info", "Stellar health monitoring stopped", {});
+}
+
+/**
+ * Subscribes to health check updates
+ * @returns Unsubscribe function
+ */
+export function subscribe(callback: (result: HealthCheckResult) => void): () => void {
+ listeners.add(callback);
+ // Immediately send current state if available
+ if (lastCheck) {
+ callback(lastCheck);
+ }
+ return () => {
+ listeners.delete(callback);
+ };
+}
+
+/**
+ * Gets the current health status
+ */
+export function getCurrentStatus(): HealthCheckResult | null {
+ return lastCheck;
+}
+
+/**
+ * Gets the current network status
+ */
+export function getNetworkStatus(): NetworkStatus {
+ return currentStatus;
+}
+
+/**
+ * Gets outage history
+ */
+export function getOutageLog(): OutageEvent[] {
+ return [...outageLog];
+}
+
+/**
+ * Gets the currently active outage (if any)
+ */
+export function getActiveOutage(): OutageEvent | null {
+ return activeOutage;
+}
+
+/**
+ * Clears outage history (useful for testing)
+ */
+export function clearOutageLog(): void {
+ outageLog = [];
+ activeOutage = null;
+}