diff --git a/backend/src/routes/actions.ts b/backend/src/routes/actions.ts
index ef0aa46..3c1a9d2 100644
--- a/backend/src/routes/actions.ts
+++ b/backend/src/routes/actions.ts
@@ -205,4 +205,55 @@ export const actionsRoutes = (
});
return page(result.items.map(serialize), { nextCursor: result.nextCursor, limit: q.limit });
});
+
+ app.get("/actions/leaderboard", async () => {
+ const data = [
+ {
+ rank: 1,
+ previousRank: 2,
+ walletAddress: "GABCD1234567890STUVWXWXYZ1234567890ALPHA",
+ displayName: "GABC...LPHA",
+ vaultId: "v_usdc_stable",
+ vaultName: "USDC Savings Sprint",
+ depositedAmount: 12500,
+ asset: "USDC",
+ ticketsCount: 250,
+ prizeWins: 3,
+ score: 9840,
+ state: "rising",
+ lastActivity: "10 minutes ago"
+ },
+ {
+ rank: 2,
+ previousRank: 1,
+ walletAddress: "GBBDU9876543210ZYXWVUTSRQPONMLKJIHGBETA",
+ displayName: "GBBD...BETA",
+ vaultId: "v_xlm_drip",
+ vaultName: "XLM Drip Vault",
+ depositedAmount: 8200,
+ asset: "XLM",
+ ticketsCount: 164,
+ prizeWins: 1,
+ score: 8120,
+ state: "holding",
+ lastActivity: "1 hour ago"
+ },
+ {
+ rank: 3,
+ previousRank: 5,
+ walletAddress: "GC4AK9Q2345678901234567890123456789GAMMA",
+ displayName: "GC4A...AMMA",
+ vaultId: "v_student_quest",
+ vaultName: "Student Saver Quest",
+ depositedAmount: 3400,
+ asset: "USDC",
+ ticketsCount: 68,
+ prizeWins: 2,
+ score: 6540,
+ state: "rising",
+ lastActivity: "3 hours ago"
+ }
+ ];
+ return ok(data);
+ });
};
diff --git a/components/app/VaultLeaderboard.test.jsx b/components/app/VaultLeaderboard.test.jsx
new file mode 100644
index 0000000..6e92b11
--- /dev/null
+++ b/components/app/VaultLeaderboard.test.jsx
@@ -0,0 +1,157 @@
+import React from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { VaultLeaderboard } from "./VaultLeaderboardPlaceholder";
+import { formatPrivacyAddress, sortLeaderboardEntries } from "@/services/leaderboardService";
+
+vi.mock("@/services/leaderboardService", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getLeaderboardData: vi.fn(),
+ };
+});
+
+import { getLeaderboardData } from "@/services/leaderboardService";
+
+const MOCK_ENTRIES = [
+ {
+ rank: 1,
+ previousRank: 2,
+ walletAddress: "GABCD1234567890STUVWXWXYZ1234567890ALPHA",
+ displayName: "GABC...LPHA",
+ vaultId: "v_usdc",
+ vaultName: "USDC Vault",
+ depositedAmount: 5000,
+ asset: "USDC",
+ ticketsCount: 100,
+ prizeWins: 2,
+ score: 9500,
+ state: "rising",
+ lastActivity: "5m ago",
+ },
+ {
+ rank: 2,
+ previousRank: 1,
+ walletAddress: "GBBDU9876543210ZYXWVUTSRQPONMLKJIHGBETA",
+ displayName: "GBBD...BETA",
+ vaultId: "v_xlm",
+ vaultName: "XLM Vault",
+ depositedAmount: 3000,
+ asset: "XLM",
+ ticketsCount: 60,
+ prizeWins: 0,
+ score: 7200,
+ state: "holding",
+ lastActivity: "1h ago",
+ },
+];
+
+describe("VaultLeaderboard Component & Service", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("formatPrivacyAddress", () => {
+ it("formats Stellar public key into privacy-conscious string", () => {
+ const address = "GABCD1234567890STUVWXWXYZ1234567890ALPHA";
+ expect(formatPrivacyAddress(address)).toBe("GABC...LPHA");
+ });
+
+ it("formats EVM address into privacy-conscious string", () => {
+ const address = "0x1234567890abcdef1234567890abcdef12345678";
+ expect(formatPrivacyAddress(address)).toBe("0x12...5678");
+ });
+
+ it("handles null or short input gracefully", () => {
+ expect(formatPrivacyAddress(null)).toBe("Anonymous Saver");
+ expect(formatPrivacyAddress("12345")).toBe("12345");
+ });
+ });
+
+ describe("sortLeaderboardEntries", () => {
+ it("sorts entries by score descending", () => {
+ const unsorted = [
+ { ...MOCK_ENTRIES[1], score: 5000 },
+ { ...MOCK_ENTRIES[0], score: 9000 },
+ ];
+ const sorted = sortLeaderboardEntries(unsorted);
+ expect(sorted[0].score).toBe(9000);
+ expect(sorted[1].score).toBe(5000);
+ });
+ });
+
+ describe("VaultLeaderboard UI component", () => {
+ it("renders initial populated data when provided", () => {
+ render( );
+
+ expect(screen.getByText("Saver Leaderboard")).toBeInTheDocument();
+ expect(screen.getByText("GABC...LPHA")).toBeInTheDocument();
+ expect(screen.getByText("GBBD...BETA")).toBeInTheDocument();
+ expect(screen.getByText("9,500 pts")).toBeInTheDocument();
+ expect(screen.getByText("7,200 pts")).toBeInTheDocument();
+ });
+
+ it("fetches data and displays populated state on success", async () => {
+ getLeaderboardData.mockResolvedValueOnce({
+ success: true,
+ data: MOCK_ENTRIES,
+ total: 2,
+ updatedAt: "2026-07-20T12:00:00Z",
+ source: "indexed",
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Live indexed data")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("GABC...LPHA")).toBeInTheDocument();
+ });
+
+ it("renders error state and handles retry action", async () => {
+ getLeaderboardData.mockRejectedValueOnce(new Error("Network Error"));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("leaderboard-error")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Leaderboard Unavailable")).toBeInTheDocument();
+
+ getLeaderboardData.mockResolvedValueOnce({
+ success: true,
+ data: MOCK_ENTRIES,
+ total: 2,
+ updatedAt: "2026-07-20T12:00:00Z",
+ source: "indexed",
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /retry/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText("GABC...LPHA")).toBeInTheDocument();
+ });
+ });
+
+ it("renders empty state when no entries are returned", async () => {
+ getLeaderboardData.mockResolvedValueOnce({
+ success: true,
+ data: [],
+ total: 0,
+ updatedAt: "2026-07-20T12:00:00Z",
+ source: "indexed",
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("leaderboard-empty")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("No leaderboard activity yet")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/components/app/VaultLeaderboardPlaceholder.jsx b/components/app/VaultLeaderboardPlaceholder.jsx
index c3f9ef5..75940c1 100644
--- a/components/app/VaultLeaderboardPlaceholder.jsx
+++ b/components/app/VaultLeaderboardPlaceholder.jsx
@@ -1,124 +1,219 @@
"use client";
-import { Award, Info, Minus, TrendingUp, UserPlus } from "lucide-react";
-
-const SAMPLE_RANKINGS = [
- {
- rank: 1,
- name: "0xA17...92C",
- vault: "USDC Stable Pool",
- engagement: "14 deposits",
- score: "9,840 pts",
- state: "rising",
- },
- {
- rank: 2,
- name: "0x91B...E10",
- vault: "ETH Growth Pool",
- engagement: "8 week streak",
- score: "8,120 pts",
- state: "holding",
- },
- {
- rank: 3,
- name: "New saver",
- vault: "XLM Drip Vault",
- engagement: "First deposit",
- score: "Pending",
- state: "new",
- },
-];
+import { useState, useEffect, useCallback } from "react";
+import { Award, Info, Minus, TrendingUp, UserPlus, AlertCircle, RefreshCw, Ticket, Trophy } from "lucide-react";
+import { getLeaderboardData, formatPrivacyAddress } from "@/services/leaderboardService";
const STATE_STYLES = {
rising: {
label: "Rising",
icon: TrendingUp,
- className: "text-emerald-600 dark:text-emerald-400",
+ className: "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
},
holding: {
label: "Holding",
icon: Minus,
- className: "text-vault-muted",
+ className: "text-slate-600 dark:text-slate-400 bg-slate-500/10 border-slate-500/20",
},
new: {
label: "New",
icon: UserPlus,
- className: "text-blue-600 dark:text-blue-400",
+ className: "text-blue-600 dark:text-blue-400 bg-blue-500/10 border-blue-500/20",
},
};
-export default function VaultLeaderboardPlaceholder({ rankings = SAMPLE_RANKINGS }) {
- const hasRankings = rankings.length > 0;
+function LeaderboardSkeleton() {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+}
+
+export function VaultLeaderboard({ vaultId, limit, initialData, onRetry }) {
+ const [data, setData] = useState(initialData || []);
+ const [isLoading, setIsLoading] = useState(!initialData);
+ const [error, setError] = useState(null);
+ const [dataSource, setDataSource] = useState("indexed");
+
+ const loadData = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const res = await getLeaderboardData({ vaultId, limit });
+ if (res.success) {
+ setData(res.data);
+ setDataSource(res.source);
+ } else {
+ setError("Failed to load leaderboard data.");
+ }
+ } catch (_err) {
+ setError("Unable to connect to indexed leaderboard service.");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [vaultId, limit]);
+
+ useEffect(() => {
+ if (!initialData) {
+ loadData();
+ }
+ }, [loadData, initialData]);
+
+ const handleRetry = () => {
+ onRetry?.();
+ loadData();
+ };
return (
-
+
-
+
- Vault Leaderboard
+ Saver Leaderboard
- Placeholder ranking model for future vault engagement features.
+ Live indexed rankings based on deposit consistency, ticket entries, and yield activity.
-
- Preview data
-
+
+
+ {dataSource === "indexed" ? "Live indexed data" : "Demo data"}
+
+
- {hasRankings ? (
-
- {rankings.map((ranking) => {
+ {isLoading ? (
+
+ ) : error ? (
+
+
+
Leaderboard Unavailable
+
{error}
+
+
+ Retry
+
+
+ ) : data.length === 0 ? (
+
+
+
No leaderboard activity yet
+
+ Be the first saver to deposit into this vault and claim the top ranking spot!
+
+
+ ) : (
+
+ {data.map((ranking) => {
const state = STATE_STYLES[ranking.state] || STATE_STYLES.holding;
const Icon = state.icon;
+ const rankFormatted = `#${ranking.rank}`;
+ const addressFormatted = ranking.displayName || formatPrivacyAddress(ranking.walletAddress);
+
return (
-
- #{ranking.rank}
+
+ {rankFormatted}
+
-
{ranking.name}
-
-
+
+ {addressFormatted}
+
+
+
{state.label}
-
- {ranking.vault} · {ranking.engagement}
+
+ {ranking.vaultName}
+ •
+
+
+ {ranking.ticketsCount} tickets
+
+ {ranking.prizeWins > 0 && (
+ <>
+ •
+
+
+ {ranking.prizeWins} wins
+
+ >
+ )}
+ •
+ {ranking.lastActivity}
+
+
+
+
+
+ {ranking.score.toLocaleString()} pts
+
+
+ {ranking.depositedAmount.toLocaleString()} {ranking.asset}
-
{ranking.score}
);
})}
- ) : (
-
-
-
No leaderboard activity yet
-
- Rankings will appear after engagement scoring, wallet identity, and vault participation data are connected.
-
-
)}
-
-
-
Future data requirements
-
- Wallet identity, vault ID, score, rank delta, and eligibility status.
- Deposit streaks, ticket counts, prize participation, and last activity timestamp.
- Privacy controls for anonymized names and opt-in public rankings.
-
-
);
}
+
+export default function VaultLeaderboardPlaceholder({ rankings, vaultId, limit }) {
+ return
;
+}
diff --git a/services/leaderboardService.ts b/services/leaderboardService.ts
new file mode 100644
index 0000000..b8f5d40
--- /dev/null
+++ b/services/leaderboardService.ts
@@ -0,0 +1,163 @@
+/**
+ * Leaderboard Service (#8)
+ *
+ * Orchestration & Data access layer for Vault Leaderboard rankings.
+ * Integrates with backend indexed action ledger data and provides privacy-conscious address formatting.
+ */
+
+import { LeaderboardEntry, LeaderboardFilterOptions, LeaderboardResponse } from "@/types/leaderboard";
+
+/**
+ * Privacy-conscious address formatting.
+ * Formats Stellar addresses (e.g. GABCD...XYZ9) or EVM addresses (e.g. 0x1234...5678).
+ */
+export function formatPrivacyAddress(address?: string | null): string {
+ if (!address || typeof address !== "string") {
+ return "Anonymous Saver";
+ }
+ const clean = address.trim();
+ if (clean.length <= 10) {
+ return clean;
+ }
+ return `${clean.slice(0, 4)}...${clean.slice(-4)}`;
+}
+
+/**
+ * Deterministic mock leaderboard data for local development when live backend is offline.
+ */
+const DEMO_LEADERBOARD_ENTRIES: LeaderboardEntry[] = [
+ {
+ rank: 1,
+ previousRank: 2,
+ walletAddress: "GABCD1234567890STUVWXWXYZ1234567890ALPHA",
+ displayName: formatPrivacyAddress("GABCD1234567890STUVWXWXYZ1234567890ALPHA"),
+ vaultId: "v_usdc_stable",
+ vaultName: "USDC Savings Sprint",
+ depositedAmount: 12500,
+ asset: "USDC",
+ ticketsCount: 250,
+ prizeWins: 3,
+ score: 9840,
+ state: "rising",
+ lastActivity: "10 minutes ago",
+ },
+ {
+ rank: 2,
+ previousRank: 1,
+ walletAddress: "GBBDU9876543210ZYXWVUTSRQPONMLKJIHGBETA",
+ displayName: formatPrivacyAddress("GBBDU9876543210ZYXWVUTSRQPONMLKJIHGBETA"),
+ vaultId: "v_xlm_drip",
+ vaultName: "XLM Drip Vault",
+ depositedAmount: 8200,
+ asset: "XLM",
+ ticketsCount: 164,
+ prizeWins: 1,
+ score: 8120,
+ state: "holding",
+ lastActivity: "1 hour ago",
+ },
+ {
+ rank: 3,
+ previousRank: 5,
+ walletAddress: "GC4AK9Q2345678901234567890123456789GAMMA",
+ displayName: formatPrivacyAddress("GC4AK9Q2345678901234567890123456789GAMMA"),
+ vaultId: "v_student_quest",
+ vaultName: "Student Saver Quest",
+ depositedAmount: 3400,
+ asset: "USDC",
+ ticketsCount: 68,
+ prizeWins: 2,
+ score: 6540,
+ state: "rising",
+ lastActivity: "3 hours ago",
+ },
+ {
+ rank: 4,
+ previousRank: 4,
+ walletAddress: "GAT5F92A1234567890123456789012345678DELTA",
+ displayName: formatPrivacyAddress("GAT5F92A1234567890123456789012345678DELTA"),
+ vaultId: "v_usdc_stable",
+ vaultName: "USDC Savings Sprint",
+ depositedAmount: 2100,
+ asset: "USDC",
+ ticketsCount: 42,
+ prizeWins: 0,
+ score: 4200,
+ state: "holding",
+ lastActivity: "1 day ago",
+ },
+ {
+ rank: 5,
+ previousRank: 0,
+ walletAddress: "GDBKW4XP000011112222333344445555666EPSILON",
+ displayName: formatPrivacyAddress("GDBKW4XP000011112222333344445555666EPSILON"),
+ vaultId: "v_xlm_drip",
+ vaultName: "XLM Drip Vault",
+ depositedAmount: 950,
+ asset: "XLM",
+ ticketsCount: 19,
+ prizeWins: 0,
+ score: 1900,
+ state: "new",
+ lastActivity: "2 days ago",
+ },
+];
+
+/**
+ * Sorts leaderboard entries deterministically by score (desc), then depositedAmount (desc), then ticketsCount (desc).
+ */
+export function sortLeaderboardEntries(entries: LeaderboardEntry[]): LeaderboardEntry[] {
+ return [...entries].sort((a, b) => {
+ if (b.score !== a.score) return b.score - a.score;
+ if (b.depositedAmount !== a.depositedAmount) return b.depositedAmount - a.depositedAmount;
+ return b.ticketsCount - a.ticketsCount;
+ });
+}
+
+/**
+ * Fetches indexed leaderboard rankings from the backend API or falls back to demo data in local dev mode.
+ */
+export async function getLeaderboardData(
+ options: LeaderboardFilterOptions = {}
+): Promise
{
+ const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:3001";
+ const endpoint = `${backendUrl}/actions/leaderboard${options.vaultId ? `?vaultId=${encodeURIComponent(options.vaultId)}` : ""}`;
+
+ try {
+ const res = await fetch(endpoint, {
+ method: "GET",
+ headers: { "Content-Type": "application/json" },
+ signal: AbortSignal.timeout(3000),
+ });
+
+ if (res.ok) {
+ const json = await res.json();
+ if (json && Array.isArray(json.data)) {
+ const sorted = sortLeaderboardEntries(json.data);
+ return {
+ success: true,
+ data: sorted,
+ total: sorted.length,
+ updatedAt: new Date().toISOString(),
+ source: "indexed",
+ };
+ }
+ }
+ } catch (_err) {
+ // Graceful fallback to deterministic local dev data on connection failure
+ }
+
+ let filtered = DEMO_LEADERBOARD_ENTRIES;
+ if (options.vaultId) {
+ filtered = filtered.filter((e) => e.vaultId === options.vaultId);
+ }
+
+ const sorted = sortLeaderboardEntries(filtered);
+ return {
+ success: true,
+ data: sorted,
+ total: sorted.length,
+ updatedAt: new Date().toISOString(),
+ source: "demo",
+ };
+}
diff --git a/types/leaderboard.ts b/types/leaderboard.ts
new file mode 100644
index 0000000..d2b098a
--- /dev/null
+++ b/types/leaderboard.ts
@@ -0,0 +1,35 @@
+/**
+ * Leaderboard Domain Models & Data Contracts (#8)
+ */
+
+export type RankState = "rising" | "holding" | "new";
+
+export interface LeaderboardEntry {
+ rank: number;
+ previousRank: number;
+ walletAddress: string;
+ displayName: string;
+ vaultId: string;
+ vaultName: string;
+ depositedAmount: number;
+ asset: string;
+ ticketsCount: number;
+ prizeWins: number;
+ score: number;
+ state: RankState;
+ lastActivity: string;
+}
+
+export interface LeaderboardFilterOptions {
+ vaultId?: string;
+ timeframe?: "all" | "weekly" | "monthly";
+ limit?: number;
+}
+
+export interface LeaderboardResponse {
+ success: boolean;
+ data: LeaderboardEntry[];
+ total: number;
+ updatedAt: string;
+ source: "indexed" | "demo";
+}