From 00f2dde34e6ca87cbfec7a2d674bf54c47ad52a3 Mon Sep 17 00:00:00 2001 From: Bot Date: Mon, 22 Jun 2026 21:26:10 +0100 Subject: [PATCH] feat(dashboard): build Stellar activity dashboard for ownership and payment events --- __tests__/api/stellar/activity.test.ts | 151 ++++ __tests__/api/stellar/sync.test.ts | 74 ++ app/api/admin/stellar/sync/route.ts | 29 + app/api/stellar/activity/route.ts | 231 ++++++ app/dashboard/admin/stellar/page.tsx | 14 + app/dashboard/investor/stellar/page.tsx | 33 + components/dashboard/sidebar.tsx | 3 + .../dashboard/stellar-activity-dashboard.tsx | 710 ++++++++++++++++++ docs/STELLAR_DASHBOARD_SUMMARY.md | 80 ++ 9 files changed, 1325 insertions(+) create mode 100644 __tests__/api/stellar/activity.test.ts create mode 100644 __tests__/api/stellar/sync.test.ts create mode 100644 app/api/admin/stellar/sync/route.ts create mode 100644 app/api/stellar/activity/route.ts create mode 100644 app/dashboard/admin/stellar/page.tsx create mode 100644 app/dashboard/investor/stellar/page.tsx create mode 100644 components/dashboard/stellar-activity-dashboard.tsx create mode 100644 docs/STELLAR_DASHBOARD_SUMMARY.md diff --git a/__tests__/api/stellar/activity.test.ts b/__tests__/api/stellar/activity.test.ts new file mode 100644 index 0000000..4fd8950 --- /dev/null +++ b/__tests__/api/stellar/activity.test.ts @@ -0,0 +1,151 @@ +import { NextResponse } from "next/server" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { requireAuthenticatedUser, finalizeAuthenticatedResponse, getStellarConfig, find, loadAccount } = vi.hoisted(() => ({ + requireAuthenticatedUser: vi.fn(), + finalizeAuthenticatedResponse: vi.fn(async (response: unknown) => response), + getStellarConfig: vi.fn(), + find: vi.fn(), + loadAccount: vi.fn(), +})) + +vi.mock("@/lib/api/route-guard", () => ({ requireAuthenticatedUser, finalizeAuthenticatedResponse })) +vi.mock("@/lib/dbConnect", () => ({ default: vi.fn() })) +vi.mock("@/lib/stellar/config", () => ({ getStellarConfig })) +vi.mock("@/lib/stellar/client", () => ({ + getStellarClient: vi.fn(() => ({ + horizon: { + loadAccount, + }, + })), +})) + +vi.mock("@/models/StellarIndexedEvent", () => ({ + default: { + find, + }, +})) + +import { GET } from "@/app/api/stellar/activity/route" + +function buildRequest() { + return new Request("http://localhost/api/stellar/activity", { + method: "GET", + }) +} + +function buildUser(overrides: Record = {}): Record { + return { + _id: "user-1", + name: "Test User", + email: "test@example.com", + role: "investor", + stellarPublicKey: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", + ...overrides, + } +} + +describe("GET /api/stellar/activity", () => { + beforeEach(() => { + requireAuthenticatedUser.mockReset() + finalizeAuthenticatedResponse.mockClear() + getStellarConfig.mockReset() + find.mockReset() + loadAccount.mockReset() + }) + + it("returns 401/error if not authenticated", async () => { + requireAuthenticatedUser.mockResolvedValue({ + response: NextResponse.json({ message: "Unauthorized" }, { status: 401 }), + }) + + const response = await GET(buildRequest()) + expect(response.status).toBe(401) + }) + + it("returns mock data when mock mode is enabled", async () => { + const user = buildUser() + requireAuthenticatedUser.mockResolvedValue({ user }) + getStellarConfig.mockReturnValue({ + mock: true, + network: "testnet", + horizonUrl: "https://horizon-testnet.stellar.org", + rpcUrl: "https://soroban-testnet.stellar.org", + contractId: "C123", + issuerPublicKey: "GD123", + }) + + const response = await GET(buildRequest()) + const payload = await response.json() + + expect(response.status).toBe(200) + expect(payload.mock).toBe(true) + expect(payload.balances.length).toBeGreaterThan(0) + expect(payload.activities.length).toBeGreaterThan(0) + expect(loadAccount).not.toHaveBeenCalled() + }) + + it("returns empty balances/activities if live mode but no public key is linked", async () => { + const user = buildUser({ stellarPublicKey: null }) + requireAuthenticatedUser.mockResolvedValue({ user }) + getStellarConfig.mockReturnValue({ + mock: false, + network: "testnet", + }) + + const response = await GET(buildRequest()) + const payload = await response.json() + + expect(response.status).toBe(200) + expect(payload.balances).toEqual([]) + expect(payload.activities).toEqual([]) + }) + + it("fetches live balances and database events when live mode and key is linked", async () => { + const user = buildUser() + requireAuthenticatedUser.mockResolvedValue({ user }) + getStellarConfig.mockReturnValue({ + mock: false, + network: "testnet", + }) + + loadAccount.mockResolvedValue({ + balances: [ + { asset_type: "native", balance: "150.00" }, + { asset_type: "credit_alphanum4", asset_code: "USDC", balance: "20.00", asset_issuer: "GDUSDC" }, + ], + }) + + find.mockReturnValue({ + sort: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue([ + { + _id: "tx-1", + chainMoveRecordType: "repayment", + eventType: "payment", + amount: "100.00", + asset: "CMOVE", + sourceAccount: user.stellarPublicKey, + destinationAccount: "GBX", + createdAt: new Date(), + raw: { transaction_hash: "hash-1" }, + }, + ]), + }), + }), + }) + + const response = await GET(buildRequest()) + const payload = await response.json() + + expect(response.status).toBe(200) + expect(loadAccount).toHaveBeenCalledWith(user.stellarPublicKey) + expect(payload.balances).toEqual([ + { asset: "XLM", balance: "150.00", type: "native", issuer: null }, + { asset: "USDC", balance: "20.00", type: "credit_alphanum4", issuer: "GDUSDC" }, + ]) + expect(payload.activities[0].id).toBe("tx-1") + expect(payload.activities[0].title).toBe("Repayment Settlement") + }) +}) diff --git a/__tests__/api/stellar/sync.test.ts b/__tests__/api/stellar/sync.test.ts new file mode 100644 index 0000000..94f2fd9 --- /dev/null +++ b/__tests__/api/stellar/sync.test.ts @@ -0,0 +1,74 @@ +import { NextResponse } from "next/server" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { requireAuthenticatedUser, finalizeAuthenticatedResponse, sync } = vi.hoisted(() => ({ + requireAuthenticatedUser: vi.fn(), + finalizeAuthenticatedResponse: vi.fn(async (response: unknown) => response), + sync: vi.fn(), +})) + +vi.mock("@/lib/api/route-guard", () => ({ requireAuthenticatedUser, finalizeAuthenticatedResponse })) +vi.mock("@/lib/dbConnect", () => ({ default: vi.fn() })) +vi.mock("@/lib/stellar/indexer", () => ({ + createStellarIndexer: vi.fn(() => ({ + sync, + })), +})) + +import { POST } from "@/app/api/admin/stellar/sync/route" + +function buildRequest() { + return new Request("http://localhost/api/admin/stellar/sync", { + method: "POST", + }) +} + +function buildUser(overrides: Record = {}): Record { + return { + _id: "admin-1", + name: "Admin User", + email: "admin@example.com", + role: "admin", + ...overrides, + } +} + +describe("POST /api/admin/stellar/sync", () => { + beforeEach(() => { + requireAuthenticatedUser.mockReset() + finalizeAuthenticatedResponse.mockClear() + sync.mockReset() + }) + + it("returns 401/error if not authenticated as admin", async () => { + requireAuthenticatedUser.mockResolvedValue({ + response: NextResponse.json({ message: "Unauthorized" }, { status: 401 }), + }) + + const response = await POST(buildRequest()) + expect(response.status).toBe(401) + expect(sync).not.toHaveBeenCalled() + }) + + it("triggers sync and returns metrics when authenticated as admin", async () => { + const user = buildUser() + requireAuthenticatedUser.mockResolvedValue({ user }) + sync.mockResolvedValue({ + processed: 5, + duplicates: 2, + errors: 0, + lastCursor: "cursor-123", + }) + + const response = await POST(buildRequest()) + const payload = await response.json() + + expect(response.status).toBe(200) + expect(sync).toHaveBeenCalledTimes(1) + expect(payload.success).toBe(true) + expect(payload.processed).toBe(5) + expect(payload.duplicates).toBe(2) + expect(payload.errors).toBe(0) + expect(payload.lastCursor).toBe("cursor-123") + }) +}) diff --git a/app/api/admin/stellar/sync/route.ts b/app/api/admin/stellar/sync/route.ts new file mode 100644 index 0000000..7c08751 --- /dev/null +++ b/app/api/admin/stellar/sync/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server" +import { finalizeAuthenticatedResponse, requireAuthenticatedUser } from "@/lib/api/route-guard" +import dbConnect from "@/lib/dbConnect" +import { createStellarIndexer } from "@/lib/stellar/indexer" + +export async function POST(request: Request) { + try { + const auth = await requireAuthenticatedUser(request, ["admin"]) + if ("response" in auth) return auth.response + + await dbConnect() + + const indexer = createStellarIndexer() + const result = await indexer.sync() + + const response = NextResponse.json({ + success: true, + processed: result.processed, + duplicates: result.duplicates, + errors: result.errors, + lastCursor: result.lastCursor, + }) + + return finalizeAuthenticatedResponse(response, auth) + } catch (error) { + console.error("STELLAR_SYNC_API_ERROR", error) + return NextResponse.json({ error: "Failed to sync Stellar indexer" }, { status: 500 }) + } +} diff --git a/app/api/stellar/activity/route.ts b/app/api/stellar/activity/route.ts new file mode 100644 index 0000000..13cfeea --- /dev/null +++ b/app/api/stellar/activity/route.ts @@ -0,0 +1,231 @@ +import { NextResponse } from "next/server" +import { getStellarConfig } from "@/lib/stellar/config" +import { getStellarClient } from "@/lib/stellar/client" +import { finalizeAuthenticatedResponse, requireAuthenticatedUser } from "@/lib/api/route-guard" +import dbConnect from "@/lib/dbConnect" +import StellarIndexedEvent from "@/models/StellarIndexedEvent" + +export async function GET(request: Request) { + try { + const auth = await requireAuthenticatedUser(request, ["admin", "driver", "investor"]) + if ("response" in auth) return auth.response + + const config = getStellarConfig() + const { user } = auth + const isMock = config.mock + + // Base stellar information to return + const stellarInfo = { + network: config.network, + networkLabel: config.network === "mainnet" ? "Stellar Mainnet" : "Stellar Testnet", + horizonUrl: config.horizonUrl, + rpcUrl: config.rpcUrl, + contractId: config.contractId, + mock: isMock, + linkedAccount: user.stellarPublicKey || null, + } + + if (isMock) { + // Return mock balances and activities + const demoAccount = user.stellarPublicKey || "GD3MOCKACCOUNT123456789" + + const mockBalances = [ + { asset: "XLM", balance: "10000.00", type: "native" }, + { asset: "USDC", balance: "2450.50", type: "credit", issuer: "GBBD47IF2H737MZRLT27725J5N5F3GZLU54B7S5XZPZ2GCK4V72UUMOO" }, + { asset: "CMOVE", balance: "5000.00", type: "credit", issuer: config.issuerPublicKey || "GCFZPZ2GCK4V72UUMOO000000000000000000000000000000000000000" } + ] + + const mockActivities = [ + { + id: "mock-stellar-act-1", + chainMoveRecordType: "repayment", + eventType: "payment", + title: "Repayment Settlement", + amount: "150.00", + asset: "CMOVE", + date: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(), + status: "Confirmed", + sourceAccount: demoAccount, + destinationAccount: "GBXDistributionAccount0000000000000000000000000000000000", + reference: "f2f6b7c8c9d04de1b7e6a31d5b8f1b57c3f3d0a0d8a1b4e7c1c7e4f6b7c8d9a0" + }, + { + id: "mock-stellar-act-2", + chainMoveRecordType: "pool_investment", + eventType: "payment", + title: "Pool Share Allocation", + amount: "2500.00", + asset: "CMOVE", + date: new Date(Date.now() - 1000 * 60 * 60 * 12).toISOString(), + status: "Confirmed", + sourceAccount: demoAccount, + destinationAccount: "GABCDMOCKSTELLARPUBLICKEYTESTNET000000000000000000000000000002", + reference: "c1f1b3d1f4aa4f4c9c23f4f2f1e0d5b6a8c2e3f4d5c6b7a8c9d0e1f2a3b4c5d6" + }, + { + id: "mock-stellar-act-3", + chainMoveRecordType: "payout", + eventType: "payment", + title: "Quarterly Profit Payout", + amount: "350.00", + asset: "USDC", + date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(), + status: "Confirmed", + sourceAccount: "GABCDMOCKSTELLARPUBLICKEYTESTNET000000000000000000000000000003", + destinationAccount: demoAccount, + reference: "d9a8c7b6a5e4d3c2b1a0f9e8d7c6b5a4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8" + }, + { + id: "mock-stellar-act-4", + chainMoveRecordType: "wallet_funding", + eventType: "create_account", + title: "Stellar Account Activated", + amount: "1000.00", + asset: "XLM", + date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString(), + status: "Confirmed", + sourceAccount: "GABCDMOCKSTELLARPUBLICKEYTESTNET000000000000000000000000000004", + destinationAccount: demoAccount, + reference: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + }, + { + id: "mock-stellar-act-5", + chainMoveRecordType: "contract_interaction", + eventType: "invoke_host_function", + title: "Soroban Pool Deposit", + amount: "2500.00", + asset: "CMOVE", + date: new Date(Date.now() - 1000 * 60 * 60 * 12 - 5000).toISOString(), + status: "Confirmed", + sourceAccount: demoAccount, + destinationAccount: config.contractId || "CCMOVEPOOLCONTRACTPUBLICKEY0000000000000000000000000000000", + reference: "b3d1f4aa4f4c9c23f4f2f1e0d5b6a8c2e3f4d5c6b7a8c9d0e1f2a3b4c5d6e7f8", + sorobanEvents: [ + { + contractId: config.contractId || "CCMOVEPOOLCONTRACTPUBLICKEY0000000000000000000000000000000", + topics: ["transfer", demoAccount, config.contractId || "CCMOVEPOOLCONTRACTPUBLICKEY0000000000000000000000000000000"], + value: "2500.00 CMOVE" + }, + { + contractId: config.contractId || "CCMOVEPOOLCONTRACTPUBLICKEY0000000000000000000000000000000", + topics: ["pool_joined", demoAccount], + value: "Shares issued: 2500" + } + ] + } + ] + + const response = NextResponse.json({ + ...stellarInfo, + linkedAccount: demoAccount, + balances: mockBalances, + activities: mockActivities, + }) + return finalizeAuthenticatedResponse(response, auth) + } + + // Live mode + const stellarPublicKey = user.stellarPublicKey + if (!stellarPublicKey) { + const response = NextResponse.json({ + ...stellarInfo, + balances: [], + activities: [], + }) + return finalizeAuthenticatedResponse(response, auth) + } + + await dbConnect() + + // 1. Fetch balances from Horizon + let balances: any[] = [] + let isFunded = true + try { + const client = getStellarClient(config) + const accountInfo = await client.horizon.loadAccount(stellarPublicKey) + balances = accountInfo.balances.map((b: any) => ({ + asset: b.asset_type === "native" ? "XLM" : b.asset_code, + balance: b.balance, + type: b.asset_type, + issuer: b.asset_issuer || null, + })) + } catch (err: any) { + if (err?.response?.status === 404) { + isFunded = false + balances = [ + { asset: "XLM", balance: "0.00", type: "native", isPlaceholder: true } + ] + } else { + console.error("HORIZON_ACCOUNT_LOAD_ERROR", err) + } + } + + // 2. Query MongoDB for events + let eventQuery: any = {} + if (user.role === "admin") { + // Admins see all indexed events + eventQuery = {} + } else { + // Investors/Drivers see events involving their linked key + eventQuery = { + $or: [ + { sourceAccount: stellarPublicKey }, + { destinationAccount: stellarPublicKey } + ] + } + } + + const events = await StellarIndexedEvent.find(eventQuery) + .sort({ stellarCreatedAt: -1, createdAt: -1 }) + .limit(50) + .lean() + + const activities = events.map((e: any) => { + let title = "Stellar Transaction" + switch (e.chainMoveRecordType) { + case "repayment": + title = "Repayment Settlement" + break + case "investment": + case "pool_investment": + title = "Pool Share Allocation" + break + case "wallet_funding": + title = e.eventType === "create_account" ? "Stellar Account Activated" : "Wallet Funding" + break + case "payout": + title = "Quarterly Profit Payout" + break + case "contract_interaction": + title = "Soroban Contract Call" + break + } + + return { + id: e._id, + chainMoveRecordType: e.chainMoveRecordType, + eventType: e.eventType, + title, + amount: e.amount || "0.00", + asset: e.asset || "XLM", + date: e.stellarCreatedAt || e.createdAt.toISOString(), + status: "Confirmed", + sourceAccount: e.sourceAccount, + destinationAccount: e.destinationAccount || null, + reference: e.raw?.transaction_hash || e._id, + sorobanEvents: e.eventType === "invoke_host_function" ? (e.raw?.soroban_events || []) : [], + } + }) + + const response = NextResponse.json({ + ...stellarInfo, + isFunded, + balances, + activities, + }) + return finalizeAuthenticatedResponse(response, auth) + } catch (error) { + console.error("STELLAR_ACTIVITY_API_ERROR", error) + return NextResponse.json({ error: "Failed to fetch Stellar activity" }, { status: 500 }) + } +} diff --git a/app/dashboard/admin/stellar/page.tsx b/app/dashboard/admin/stellar/page.tsx new file mode 100644 index 0000000..0633ae3 --- /dev/null +++ b/app/dashboard/admin/stellar/page.tsx @@ -0,0 +1,14 @@ +import { requireAdminAccess } from "@/src/server/admin/require-admin" +import { StellarActivityDashboard } from "@/components/dashboard/stellar-activity-dashboard" + +export const dynamic = "force-dynamic" + +export default async function AdminStellarPage() { + await requireAdminAccess() + + return ( +
+ +
+ ) +} diff --git a/app/dashboard/investor/stellar/page.tsx b/app/dashboard/investor/stellar/page.tsx new file mode 100644 index 0000000..e6e343e --- /dev/null +++ b/app/dashboard/investor/stellar/page.tsx @@ -0,0 +1,33 @@ +"use client" + +import { DashboardShell } from "@/components/dashboard/dashboard-shell" +import { Header } from "@/components/dashboard/header" +import { StellarActivityDashboard } from "@/components/dashboard/stellar-activity-dashboard" +import { useAuth } from "@/hooks/use-auth" +import { DashboardRouteLoading } from "@/components/dashboard/dashboard-route-loading" +import { DashboardUnauthorized } from "@/components/dashboard/dashboard-unauthorized" + +export default function InvestorStellarPage() { + const { user: authUser, loading: authLoading } = useAuth() + + if (authLoading) { + return ( + + ) + } + + if (!authUser || authUser.role !== "investor") { + return + } + + return ( + }> +
+ +
+
+ ) +} diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx index e779533..24e8974 100644 --- a/components/dashboard/sidebar.tsx +++ b/components/dashboard/sidebar.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState, type ComponentType } from "react" import Link from "next/link" import { usePathname, useRouter } from "next/navigation" import { + Activity, Calendar, Bell, Car, @@ -87,6 +88,7 @@ const SIDEBAR_SECTIONS: Record = { defaultExpanded: true, items: [ { label: "My Wallet", href: "/dashboard/investor/wallet", icon: Wallet }, + { label: "Stellar Activity", href: "/dashboard/investor/stellar", icon: Activity }, { label: "Transaction Ledger", href: "/dashboard/investor/ledger", icon: Receipt }, ], }, @@ -157,6 +159,7 @@ const SIDEBAR_SECTIONS: Record = { { label: "Activity", href: "/dashboard/admin/activity", icon: Bell }, { label: "Reports", href: "/dashboard/admin/reports", icon: FileText }, { label: "Transaction Ledger", href: "/dashboard/admin/ledger", icon: Receipt }, + { label: "Stellar Ledger", href: "/dashboard/admin/stellar", icon: Activity }, { label: "Issues", href: "/dashboard/admin/issues", icon: ShieldAlert }, { label: "Governance", href: "/dashboard/admin/governance", icon: Vote }, ], diff --git a/components/dashboard/stellar-activity-dashboard.tsx b/components/dashboard/stellar-activity-dashboard.tsx new file mode 100644 index 0000000..75bd64d --- /dev/null +++ b/components/dashboard/stellar-activity-dashboard.tsx @@ -0,0 +1,710 @@ +"use client" + +import { useEffect, useState, useMemo } from "react" +import { motion, AnimatePresence } from "framer-motion" +import { + Activity, + AlertCircle, + ArrowDownLeft, + ArrowUpRight, + Check, + Clock, + Coins, + Copy, + Database, + ExternalLink, + FileCode, + Info, + Link as LinkIcon, + Loader2, + RefreshCw, + Sparkles, + Wallet, +} from "lucide-react" + +import { getStellarDisplayConfig, buildStellarReferenceUrl } from "@/lib/stellar/display-config" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Skeleton } from "@/components/ui/skeleton" +import { useToast } from "@/hooks/use-toast" +import { StellarLinkForm } from "@/components/dashboard/stellar-link-form" + +interface BalanceItem { + asset: string + balance: string + type: string + issuer?: string | null + isPlaceholder?: boolean +} + +interface SorobanEvent { + contractId: string + topics: string[] + value: string +} + +interface ActivityItem { + id: string + chainMoveRecordType: string + eventType: string + title: string + amount: string + asset: string + date: string + status: string + sourceAccount: string + destinationAccount: string | null + reference: string + sorobanEvents?: SorobanEvent[] +} + +interface StellarDashboardData { + network: string + networkLabel: string + horizonUrl: string + rpcUrl: string + contractId: string + mock: boolean + linkedAccount: string | null + isFunded?: boolean + balances: BalanceItem[] + activities: ActivityItem[] +} + +interface StellarActivityDashboardProps { + role: "investor" | "admin" +} + +export function StellarActivityDashboard({ role }: StellarActivityDashboardProps) { + const { toast } = useToast() + const [data, setData] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [isRefreshing, setIsRefreshing] = useState(false) + const [isSyncing, setIsSyncing] = useState(false) + const [copiedKey, setCopiedKey] = useState(false) + const [activeTab, setActiveTab] = useState("all") + + // Load dashboard data + const loadDashboardData = async (silent = false) => { + if (!silent) setIsLoading(true) + else setIsRefreshing(true) + + try { + const response = await fetch("/api/stellar/activity") + const result = await response.json() + + if (!response.ok) { + throw new Error(result.error || "Failed to load dashboard data") + } + + setData(result) + setError(null) + } catch (err: any) { + setError(err.message || "An unexpected error occurred.") + toast({ + title: "Error loading activity", + description: err.message || "Could not connect to the Stellar service.", + variant: "destructive", + }) + } finally { + setIsLoading(false) + setIsRefreshing(false) + } + } + + useEffect(() => { + void loadDashboardData() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Sync indexer (Admin only) + const handleSyncIndexer = async () => { + setIsSyncing(true) + toast({ + title: "Syncing Indexer", + description: "Fetching latest events from the Stellar network...", + }) + + try { + const response = await fetch("/api/admin/stellar/sync", { + method: "POST", + }) + const result = await response.json() + + if (!response.ok) { + throw new Error(result.error || "Failed to sync indexer") + } + + toast({ + title: "Sync Complete", + description: `Successfully processed ${result.processed} new events. (${result.duplicates} duplicates, ${result.errors} errors).`, + }) + + // Reload dashboard data + void loadDashboardData(true) + } catch (err: any) { + toast({ + title: "Sync Failed", + description: err.message || "Could not sync the event indexer.", + variant: "destructive", + }) + } finally { + setIsSyncing(false) + } + } + + // Copy public key to clipboard + const handleCopyKey = async (key: string) => { + try { + await navigator.clipboard.writeText(key) + setCopiedKey(true) + setTimeout(() => setCopiedKey(false), 2000) + toast({ + title: "Copied", + description: "Public key copied to clipboard.", + }) + } catch { + // Fallback + } + } + + // Filter activities based on active tab + const filteredActivities = useMemo(() => { + if (!data?.activities) return [] + const activities = data.activities + + switch (activeTab) { + case "payments": + return activities.filter( + (a) => + a.chainMoveRecordType === "wallet_funding" || + a.chainMoveRecordType === "unclassified" || + a.eventType === "payment" + ) + case "pools": + return activities.filter( + (a) => + a.chainMoveRecordType === "investment" || + a.chainMoveRecordType === "pool_investment" + ) + case "payouts": + return activities.filter((a) => a.chainMoveRecordType === "payout") + case "repayments": + return activities.filter((a) => a.chainMoveRecordType === "repayment") + case "soroban": + return activities.filter( + (a) => + a.eventType === "invoke_host_function" || + a.chainMoveRecordType === "contract_interaction" || + (a.sorobanEvents && a.sorobanEvents.length > 0) + ) + default: + return activities + } + }, [data?.activities, activeTab]) + + // Get color configurations for assets + const getAssetBadgeStyles = (asset: string) => { + switch (asset.toUpperCase()) { + case "XLM": + return "bg-blue-500/10 text-blue-500 border-blue-500/20 hover:bg-blue-500/25" + case "USDC": + return "bg-emerald-500/10 text-emerald-500 border-emerald-500/20 hover:bg-emerald-500/25" + case "CMOVE": + case "MOVE": + return "bg-amber-500/10 text-amber-500 border-amber-500/20 hover:bg-amber-500/25" + default: + return "bg-muted text-muted-foreground border-border hover:bg-muted/80" + } + } + + // Get status color configuration + const getStatusColor = (status: string) => { + switch (status.toLowerCase()) { + case "confirmed": + case "completed": + return "bg-emerald-500/10 text-emerald-500 border-emerald-500/20" + case "pending": + return "bg-amber-500/10 text-amber-500 border-amber-500/20" + case "failed": + return "bg-destructive/10 text-destructive border-destructive/20" + default: + return "bg-muted text-muted-foreground border-border" + } + } + + // Get event record icon + const getEventIcon = (recordType: string) => { + switch (recordType.toLowerCase()) { + case "repayment": + return + case "payout": + return + case "investment": + case "pool_investment": + return + case "wallet_funding": + return + case "contract_interaction": + return + default: + return + } + } + + // Helper to construct link + const getExplorerLink = (ref: string) => { + if (!data) return "#" + const displayConfig = { + network: data.network as any, + explorerBaseUrl: data.network === "mainnet" + ? "https://stellar.expert/explorer/public" + : "https://stellar.expert/explorer/testnet", + mock: data.mock, + demoPublicKey: "GD3MOCKACCOUNT123456789", + } + return buildStellarReferenceUrl(ref, displayConfig) || "#" + } + + if (isLoading) { + return ( +
+
+
+ + +
+ +
+
+ + + +
+ +
+ ) + } + + if (error) { + return ( + + + + + Error Loading Stellar Dashboard + + + We couldn't load the on-chain activity because of a connectivity problem. + + + + + {error} + + + + + ) + } + + const isDemo = data?.mock || false + const activeKey = data?.linkedAccount || "" + const isAccountFunded = data?.isFunded !== false + + return ( +
+ {/* Page Header */} +
+
+

+ + Stellar Activity Hub +

+

+ {role === "admin" + ? "Monitor aggregated Stellar ledger events and system-wide asset distributions." + : "Track your personal asset ownership, payment activities, and smart contract allocations."} +

+
+ +
+ {role === "admin" && ( + + )} + + +
+
+ + {/* Network Configuration Bar */} +
+ + +
+

Stellar Network

+

{data?.networkLabel}

+
+ + {data?.network === "mainnet" ? "Production" : "Testnet"} + +
+
+ + + +
+

Ledger Integration

+

{isDemo ? "Simulated Mode" : "On-chain Mode"}

+
+ + {isDemo ? "Mock Enabled" : "Live Horizon"} + +
+
+ + + +
+

Horizon Endpoint

+

+ {data?.horizonUrl} +

+
+ +
+
+
+ + {/* Account Status / Linking Section */} + + + {!activeKey ? ( +
+
+
+

+ + No Stellar Account Linked +

+

+ Connect a Stellar public key (G...) to activate automated settlement tracking, asset distributions, and repayments. +

+
+
+
+ void loadDashboardData(true)} /> +
+
+ ) : ( +
+
+
+
+

Linked Public Key

+ {isDemo && ( + + Demo Account + + )} + {!isAccountFunded && ( + + Unfunded on Testnet + + )} +
+
+ {activeKey} +
+ + +
+
+
+ +
+ + Linked Successful + + {!isDemo && ( + + )} +
+
+ + {!isAccountFunded && ( + + + Account Unfunded + + This public key has not been funded yet. Fund it with friendbot or transfer XLM on testnet to activate your account. + + + )} +
+ )} +
+
+ + {/* Asset Balances Grid */} + {activeKey && ( +
+

+ + Asset Ownership Records +

+
+ {data?.balances && data.balances.length > 0 ? ( + data.balances.map((b) => ( + +
+ + {b.asset} + + + {b.type === "native" ? "Native Asset" : "Credit Token"} + +
+
+

+ {Number(b.balance).toLocaleString("en-US", { minimumFractionDigits: 2 })} +

+

+ {b.issuer ? `Issuer: ${b.issuer.slice(0, 8)}...${b.issuer.slice(-8)}` : "Issuer: Stellar Core Ledger"} +

+
+
+ )) + ) : ( +
+ No active asset holdings found. Fund your account with asset tokens to view balances here. +
+ )} +
+
+ )} + + {/* Soroban Smart Contract details */} + + +
+ + Soroban Smart Contract Readiness +
+ + Smart contract interactions deployed to manage automated vehicle pools. + +
+ +
+
+ Active Contract Address +
+ {data?.contractId || "replace_after_deployment"} +
+
+
+ Integration Status +
+ + Soroban Ready + + + Events are actively indexed and mapped to ChainMove pools. + +
+
+
+
+
+ + {/* Activity Timeline */} +
+
+

+ + Stellar Ledger Timeline +

+
+ + + + All Events + Payments + Pool Ownership + Payouts + Repayments + Soroban logs + + + +
+
+ + + + + + + + + + + + + {filteredActivities.length > 0 ? ( + filteredActivities.map((a) => ( + + + + + + + + )) + ) : ( + + + + )} + + +
Event DetailsAsset / AmountSource / DestinationStatus / DateActions
+
+
+ {getEventIcon(a.chainMoveRecordType)} +
+
+

{a.title}

+

+ Operation ID: {a.id} +

+
+
+
+
+ + {a.eventType === "invoke_host_function" ? "—" : `${Number(a.amount).toLocaleString("en-US", { minimumFractionDigits: 2 })}`} + + + {a.eventType === "invoke_host_function" ? "Soroban Invoc" : a.asset} + +
+
+
+

+ From: {a.sourceAccount.slice(0, 6)}...{a.sourceAccount.slice(-6)} +

+ {a.destinationAccount && ( +

+ To: {a.destinationAccount.slice(0, 6)}...{a.destinationAccount.slice(-6)} +

+ )} +
+
+
+ + {a.status} + + + {new Date(a.date).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + +
+
+ +
+ + No activity found matching this filter. +
+
+
+
+
+
+
+ ) +} diff --git a/docs/STELLAR_DASHBOARD_SUMMARY.md b/docs/STELLAR_DASHBOARD_SUMMARY.md new file mode 100644 index 0000000..cc10be8 --- /dev/null +++ b/docs/STELLAR_DASHBOARD_SUMMARY.md @@ -0,0 +1,80 @@ +# Stellar Activity Dashboard Implementation Summary + +## Overview +Built a Stellar Activity Dashboard module that displays network configurations, linked account public key, asset ownership balances, Soroban smart contract status, and a categorized ledger events timeline. It is fully integrated into both the investor and admin interfaces with role-based features. + +## Architecture + +``` + ┌────────────────────────────────────────────────────────┐ + │ FRONTEND │ + │ Investor Page Admin Page │ + │ (/dashboard/investor/stellar) (/dashboard/admin/stellar) + └───────────┬───────────────────────────────────┬────────┘ + │ │ + ▼ ▼ + ┌────────────────────────────────────────────────────────┐ + │ Shared React Component │ + │ │ + └───────────────────────────┬────────────────────────────┘ + │ + ▼ (API Requests) + ┌───────────────────────────┴────────────────────────────┐ + │ BACKEND │ + │ GET /api/stellar/activity │ POST /api/admin/stellar/sync + └─────────────┬───────────────┴──────────────┬───────────┘ + │ │ + ▼ (Horizon RPC / DB) ▼ (Indexer Sync) + ┌─────────────────────────────┐ ┌───────────────────┐ + │ - Horizon.loadAccount │ │ StellarIndexer │ + │ - StellarIndexedEvent (DB) │ │ MongoDB Event │ + └─────────────────────────────┘ └───────────────────┘ +``` + +## Features Implemented + +### 1. Backend APIs +- **`/api/stellar/activity` (GET)**: + - Validates user session and role (`admin`, `driver`, `investor`). + - Identifies environment mode via `getStellarConfig().mock`. + - In Mock Mode: returns realistic demo assets (XLM, USDC, CMOVE) and mock operations covering repayments, payouts, pool investments, and Soroban contract invocation logs. + - In Live Mode: + - Queries the user's linked `stellarPublicKey`. + - Resolves account balances from the live Horizon server. Gracefully handles unfunded/new testnet accounts (returns 0.00 XLM and indicates unfunded state). + - Queries the MongoDB `StellarIndexedEvent` collection. For admins, returns all indexed events. For regular users, returns events involving their linked key. +- **`/api/admin/stellar/sync` (POST)**: + - Admin-only endpoint. + - Triggers the `StellarIndexer` service to ingest operations from Horizon. + - Returns processed, duplicate, and failed counts to update the dashboard. + +### 2. Shared UI Component (`components/dashboard/stellar-activity-dashboard.tsx`) +- Displays current network parameters (Active network, mock status, and endpoints). +- Shows linked key status, with clipboard copy features and links to Stellar.Expert. +- Integrates the `StellarLinkForm` for users who haven't linked a public key yet. +- Displays asset ownership balances in a clean, modern card grid (XLM, USDC, CMOVE). +- Includes a technical summary of Soroban readiness and contract variables. +- Renders an interactive, tabbed event feed matching the categories: + - *All Events* + - *Payments* (transfers / funding) + - *Pool Ownership* (investments) + - *Payouts* (distributions) + - *Repayments* (receipts) + - *Soroban logs* (smart contract calls with topics and values) +- Provides a "Sync Indexer" button for admin users to manually update onchain indexing. + +### 3. Navigation and Routing +- Added **Stellar Activity** to the Investor sidebar under "Finances". +- Added **Stellar Ledger** to the Admin sidebar under "Governance". +- Added client route `app/dashboard/investor/stellar/page.tsx`. +- Added server/admin route `app/dashboard/admin/stellar/page.tsx` (enforced via `requireAdminAccess`). + +## Test Coverage +Created comprehensive test suites in `__tests__/api/stellar/`: +- **`activity.test.ts`**: + - Verifies unauthorized requests are rejected (401). + - Verifies mock mode returns demo balances and activities. + - Verifies live mode queries the Horizon Server for the linked public key. + - Verifies db query scoping (admin gets all, user gets own events). +- **`sync.test.ts`**: + - Verifies only admins can call the sync route. + - Verifies sync execution is successfully triggered and returns correct metrics.