diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..7aec964
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,10 @@
+# Leaderboard Global Rank Fix — TODO
+
+- [x] Plan approved by user
+- [x] Step 1: Add `getUserRank()` to `SorobanService` in `src/lib/soroban.ts`
+- [x] Step 2: Update `useLeaderboard.ts` hook with async rank lookup + race condition guard
+- [x] Step 3: Update `src/app/leaderboard/page.tsx` to use new `userRank` for banner
+- [x] Step 4: Add tests for `getUserRank` in `src/lib/soroban.service.test.ts`
+- [x] Step 5: Create `src/hooks/useLeaderboard.test.ts` for hook tests
+- [ ] Step 6: Run tests and verify everything passes
+
diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx
index 5497c47..3b58cf0 100644
--- a/src/app/leaderboard/page.tsx
+++ b/src/app/leaderboard/page.tsx
@@ -47,6 +47,8 @@ export default function LeaderboardPage() {
totalPages,
setPage,
connectedRank,
+ userRank,
+ userRankLoading,
filteredCount,
lastRefreshed,
refresh,
@@ -84,9 +86,13 @@ export default function LeaderboardPage() {
>
Leaderboard
- {connectedRank > 0 ? (
+ {userRank != null && userRank > 0 ? (
- You are rank {connectedRank} of {filteredCount} farmers.
+ You are rank {userRank} of {filteredCount} farmers.
+
+ ) : userRankLoading ? (
+
+ Looking up your rank…
) : (
diff --git a/src/components/TvlChart/TvlChart.tsx b/src/components/TvlChart/TvlChart.tsx
index 60e56f8..47ecee6 100644
--- a/src/components/TvlChart/TvlChart.tsx
+++ b/src/components/TvlChart/TvlChart.tsx
@@ -82,6 +82,7 @@ export default function TvlChart({ poolId }: TvlChartProps) {
tick={{ fontSize: 11, fill: "#A2A2A2" }}
tickFormatter={(v: string) =>
new Date(v).toLocaleDateString(undefined, {
+ timeZone: "UTC",
month: "short",
day: "numeric",
})
@@ -111,6 +112,7 @@ export default function TvlChart({ poolId }: TvlChartProps) {
}}
labelFormatter={(label) =>
new Date(String(label)).toLocaleDateString(undefined, {
+ timeZone: "UTC",
weekday: "short",
month: "short",
day: "numeric",
diff --git a/src/hooks/useLeaderboard.test.ts b/src/hooks/useLeaderboard.test.ts
new file mode 100644
index 0000000..cdd6e1e
--- /dev/null
+++ b/src/hooks/useLeaderboard.test.ts
@@ -0,0 +1,240 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, act, waitFor } from "@testing-library/react";
+import { useLeaderboard, PAGE_SIZE } from "./useLeaderboard";
+import { sorobanService } from "@/lib/soroban";
+
+// Mock the sorobanService
+vi.mock("@/lib/soroban", () => ({
+ sorobanService: {
+ getLeaderboard: vi.fn(),
+ getUserRank: vi.fn(),
+ },
+}));
+
+const MOCK_USER = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
+const MOCK_OTHER_USER = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
+
+function makeEntry(address: string, credits: number, stake: number) {
+ return {
+ address,
+ totalCredits: credits,
+ totalStake: stake,
+ boostUtilization: 0,
+ };
+}
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ // Default: no wallet connected
+ vi.mocked(sorobanService.getLeaderboard).mockResolvedValue({
+ entries: [],
+ total: 0,
+ });
+ vi.mocked(sorobanService.getUserRank).mockResolvedValue(null);
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+describe("useLeaderboard", () => {
+ it("fetches leaderboard and rank on mount with a connected wallet", async () => {
+ // Mock leaderboard returning 25 total entries, page 1 (first 10)
+ const page1Entries = Array.from({ length: PAGE_SIZE }, (_, i) =>
+ makeEntry(`G${String(i).padStart(55, "0")}`, 100 - i, 50 - i),
+ );
+ vi.mocked(sorobanService.getLeaderboard).mockResolvedValue({
+ entries: page1Entries,
+ total: 25,
+ });
+ // User's global rank is #15 — well outside page 1
+ vi.mocked(sorobanService.getUserRank).mockResolvedValue(15);
+
+ const { result } = renderHook(() => useLeaderboard(MOCK_USER));
+
+ // Wait for initial fetch
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(sorobanService.getLeaderboard).toHaveBeenCalledWith(
+ 0,
+ PAGE_SIZE,
+ "credits",
+ );
+ expect(sorobanService.getUserRank).toHaveBeenCalledWith(
+ MOCK_USER,
+ "credits",
+ );
+
+ // Page-local connectedRank should be 0 (user not on page 1)
+ expect(result.current.connectedRank).toBe(0);
+
+ // Global userRank should be 15
+ expect(result.current.userRank).toBe(15);
+ expect(result.current.userRankLoading).toBe(false);
+
+ expect(result.current.paged).toEqual(page1Entries);
+ expect(result.current.totalPages).toBe(3);
+ });
+
+ it("returns userRank=null and userRankLoading=false when no wallet is connected", async () => {
+ const { result } = renderHook(() => useLeaderboard(null));
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.connectedRank).toBe(0);
+ expect(result.current.userRank).toBeNull();
+ expect(result.current.userRankLoading).toBe(false);
+ // getUserRank should not be called when publicKey is null
+ expect(sorobanService.getUserRank).not.toHaveBeenCalled();
+ });
+
+ it("resolves connectedRank correctly when user IS on the current page", async () => {
+ const page1Entries = [
+ makeEntry(MOCK_OTHER_USER, 100, 50),
+ makeEntry(MOCK_USER, 90, 45),
+ makeEntry("GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", 80, 40),
+ ];
+ vi.mocked(sorobanService.getLeaderboard).mockResolvedValue({
+ entries: page1Entries,
+ total: 10,
+ });
+ vi.mocked(sorobanService.getUserRank).mockResolvedValue(2);
+
+ const { result } = renderHook(() => useLeaderboard(MOCK_USER));
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ // User is at index 1 on the page → rank = offset(0) + 1 + 1 = 2
+ expect(result.current.connectedRank).toBe(2);
+ // Global rank should also be 2 (same for this small test)
+ expect(result.current.userRank).toBe(2);
+ });
+
+ it("recomputes userRank when sortKey changes", async () => {
+ vi.mocked(sorobanService.getLeaderboard).mockResolvedValue({
+ entries: [],
+ total: 0,
+ });
+ vi.mocked(sorobanService.getUserRank).mockResolvedValue(42);
+
+ const { result } = renderHook(() => useLeaderboard(MOCK_USER));
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.userRank).toBe(42);
+ expect(sorobanService.getUserRank).toHaveBeenLastCalledWith(
+ MOCK_USER,
+ "credits",
+ );
+
+ // Change sort key to "stake"
+ vi.mocked(sorobanService.getUserRank).mockResolvedValue(7);
+
+ act(() => {
+ result.current.setSortKey("stake");
+ });
+
+ // Should have been called with the new sort key
+ await waitFor(() =>
+ expect(sorobanService.getUserRank).toHaveBeenLastCalledWith(
+ MOCK_USER,
+ "stake",
+ ),
+ );
+
+ expect(result.current.userRank).toBe(7);
+ });
+
+ it("guards against stale rank responses (race condition)", async () => {
+ vi.mocked(sorobanService.getLeaderboard).mockResolvedValue({
+ entries: [],
+ total: 0,
+ });
+
+ // Make getUserRank resolve slowly, simulating a race
+ let resolveRank1!: (v: number | null) => void;
+ const rank1Promise = new Promise((resolve) => {
+ resolveRank1 = resolve;
+ });
+ vi.mocked(sorobanService.getUserRank).mockReturnValueOnce(rank1Promise);
+
+ const { result } = renderHook(() => useLeaderboard(MOCK_USER));
+
+ // Before rank1 resolves, trigger a sort change
+ vi.mocked(sorobanService.getUserRank).mockResolvedValue(99);
+ act(() => {
+ result.current.setSortKey("stake");
+ });
+
+ // Now resolve the FIRST rank call (stale — should be ignored)
+ await act(async () => {
+ resolveRank1(42);
+ // Let microtasks run
+ await Promise.resolve();
+ });
+
+ // The rank should NOT be 42 (stale), it should be 99 from the newer fetch
+ await waitFor(() => {
+ expect(result.current.userRank).not.toBe(42);
+ });
+ // Eventually it should be 99 (or whatever the latest value is)
+ await waitFor(() => {
+ expect(result.current.userRank).toBe(99);
+ });
+ });
+
+ it("returns filteredCount based on search query", async () => {
+ const entries = [
+ makeEntry("GAAA", 100, 50),
+ makeEntry("GBBB", 90, 45),
+ ];
+ vi.mocked(sorobanService.getLeaderboard).mockResolvedValue({
+ entries,
+ total: 2,
+ });
+ vi.mocked(sorobanService.getUserRank).mockResolvedValue(null);
+
+ const { result } = renderHook(() => useLeaderboard(MOCK_USER));
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.filteredCount).toBe(2);
+
+ // Apply search
+ act(() => {
+ result.current.setSearchQuery("GAAA");
+ });
+
+ // Wait for debounce
+ await vi.advanceTimersByTimeAsync(300);
+
+ expect(result.current.filteredCount).toBe(1);
+ expect(result.current.paged).toHaveLength(1);
+ });
+
+ it("auto-refreshes on interval", async () => {
+ vi.mocked(sorobanService.getLeaderboard).mockResolvedValue({
+ entries: [makeEntry(MOCK_USER, 100, 50)],
+ total: 1,
+ });
+ vi.mocked(sorobanService.getUserRank).mockResolvedValue(1);
+
+ const { result } = renderHook(() => useLeaderboard(MOCK_USER));
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(sorobanService.getLeaderboard).toHaveBeenCalledTimes(1);
+
+ // Clear the mock call count
+ vi.mocked(sorobanService.getLeaderboard).mockClear();
+ vi.mocked(sorobanService.getUserRank).mockClear();
+
+ // Advance past refresh interval
+ await vi.advanceTimersByTimeAsync(30_000);
+
+ // Should have been called again by the interval
+ expect(sorobanService.getLeaderboard).toHaveBeenCalledTimes(1);
+ expect(sorobanService.getUserRank).toHaveBeenCalledTimes(1);
+ });
+});
+
diff --git a/src/hooks/useLeaderboard.ts b/src/hooks/useLeaderboard.ts
index 83da8ad..7ac94e7 100644
--- a/src/hooks/useLeaderboard.ts
+++ b/src/hooks/useLeaderboard.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { sorobanService } from "@/lib/soroban";
export type SortKey = "credits" | "stake";
@@ -22,6 +22,13 @@ export function fetchLeaderboard(
return sorobanService.getLeaderboard(offset, limit, sortKey);
}
+export function fetchUserRank(
+ address: string,
+ sortKey: SortKey
+): Promise {
+ return sorobanService.getUserRank(address, sortKey);
+}
+
export function useLeaderboard(publicKey: string | null) {
const [entries, setEntries] = useState([]);
const [total, setTotal] = useState(0);
@@ -32,6 +39,13 @@ export function useLeaderboard(publicKey: string | null) {
const [page, setPageState] = useState(1);
const [lastRefreshed, setLastRefreshed] = useState(null);
+ // Separate state for the user's global rank (independent of pagination)
+ const [userRank, setUserRank] = useState(null);
+ const [userRankLoading, setUserRankLoading] = useState(false);
+
+ // Generation counter to guard against stale async responses (race condition #79)
+ const fetchGenerationRef = useRef(0);
+
useEffect(() => {
const id = setTimeout(() => setSearchQuery(searchInput), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(id);
@@ -42,25 +56,63 @@ export function useLeaderboard(publicKey: string | null) {
const offset = (currentPage - 1) * PAGE_SIZE;
const refresh = useCallback(() => {
+ const generation = ++fetchGenerationRef.current;
+
setIsLoading(true);
fetchLeaderboard(offset, PAGE_SIZE, sortKey)
.then(({ entries, total }) => {
+ // Guard: ignore stale responses
+ if (generation !== fetchGenerationRef.current) return;
setEntries(entries);
setTotal(total);
setLastRefreshed(new Date());
})
.catch(() => {
+ if (generation !== fetchGenerationRef.current) return;
setEntries([]);
setTotal(0);
})
- .finally(() => setIsLoading(false));
+ .finally(() => {
+ if (generation !== fetchGenerationRef.current) return;
+ setIsLoading(false);
+ });
}, [offset, sortKey]);
+ // Fetch the user's global rank independently of pagination
+ const refreshRank = useCallback(() => {
+ if (!publicKey) {
+ setUserRank(null);
+ setUserRankLoading(false);
+ return;
+ }
+
+ const generation = ++fetchGenerationRef.current;
+ setUserRankLoading(true);
+
+ fetchUserRank(publicKey, sortKey)
+ .then((rank) => {
+ if (generation !== fetchGenerationRef.current) return;
+ setUserRank(rank != null ? rank : null);
+ })
+ .catch(() => {
+ if (generation !== fetchGenerationRef.current) return;
+ setUserRank(null);
+ })
+ .finally(() => {
+ if (generation !== fetchGenerationRef.current) return;
+ setUserRankLoading(false);
+ });
+ }, [publicKey, sortKey]);
+
useEffect(() => {
refresh();
- const id = setInterval(refresh, REFRESH_MS);
+ refreshRank();
+ const id = setInterval(() => {
+ refresh();
+ refreshRank();
+ }, REFRESH_MS);
return () => clearInterval(id);
- }, [refresh]);
+ }, [refresh, refreshRank]);
const paged = searchQuery
? entries.filter((e) =>
@@ -68,6 +120,7 @@ export function useLeaderboard(publicKey: string | null) {
)
: entries;
+ // Page-local rank for highlighting the user's row in the table
const connectedRank = (() => {
if (!publicKey) return 0;
const idx = entries.findIndex((e) => e.address === publicKey);
@@ -90,6 +143,8 @@ export function useLeaderboard(publicKey: string | null) {
totalPages,
setPage: setPageState,
connectedRank,
+ userRank,
+ userRankLoading,
filteredCount: searchQuery ? paged.length : total,
lastRefreshed,
refresh,
diff --git a/src/lib/soroban.service.test.ts b/src/lib/soroban.service.test.ts
index fd9208c..d3f02a0 100644
--- a/src/lib/soroban.service.test.ts
+++ b/src/lib/soroban.service.test.ts
@@ -1462,6 +1462,263 @@ describe("SorobanService leaderboard", () => {
await expect(service.getCreditVelocity(12)).resolves.toBe("0");
});
+
+ describe("getUserRank", () => {
+ type LeaderboardInternals = {
+ fetchUserRankFromEvents: (
+ address: string,
+ sortKey: "credits" | "stake",
+ ) => Promise;
+ fetchUserRankFromApi: (
+ address: string,
+ sortKey: "credits" | "stake",
+ ) => Promise;
+ rpcServer: MockRpcServer;
+ };
+
+ function internals(service: SorobanService) {
+ return service as unknown as LeaderboardInternals;
+ }
+
+ it("returns null for empty address", async () => {
+ const { service } = makeService({ pool: false });
+
+ await expect(service.getUserRank("", "credits")).resolves.toBeNull();
+ });
+
+ it("uses API path when LEADERBOARD_API_URL is set and returns the rank", async () => {
+ const previousApi = process.env.NEXT_PUBLIC_LEADERBOARD_API_URL;
+ process.env.NEXT_PUBLIC_LEADERBOARD_API_URL =
+ "https://leaderboard.example/rankings";
+ vi.resetModules();
+ const { SorobanService: ApiSorobanService } = await import("./soroban");
+ const service = new ApiSorobanService();
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: true,
+ json: async () => ({ rank: 42 }),
+ } as Response);
+
+ try {
+ await expect(
+ service.getUserRank(USER_PUBLIC_KEY, "credits"),
+ ).resolves.toBe(42);
+ expect(fetchSpy).toHaveBeenCalledWith(
+ "https://leaderboard.example/rankings/rank?address=" +
+ encodeURIComponent(USER_PUBLIC_KEY) +
+ "&sort=credits",
+ { headers: { accept: "application/json" } },
+ );
+ } finally {
+ if (previousApi === undefined)
+ delete process.env.NEXT_PUBLIC_LEADERBOARD_API_URL;
+ else process.env.NEXT_PUBLIC_LEADERBOARD_API_URL = previousApi;
+ vi.resetModules();
+ }
+ });
+
+ it("API path returns null when rank is null in response", async () => {
+ const previousApi = process.env.NEXT_PUBLIC_LEADERBOARD_API_URL;
+ process.env.NEXT_PUBLIC_LEADERBOARD_API_URL =
+ "https://leaderboard.example/rankings";
+ vi.resetModules();
+ const { SorobanService: ApiSorobanService } = await import("./soroban");
+ const service = new ApiSorobanService();
+ vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: true,
+ json: async () => ({ rank: null }),
+ } as Response);
+
+ try {
+ await expect(
+ service.getUserRank(USER_PUBLIC_KEY, "stake"),
+ ).resolves.toBeNull();
+ } finally {
+ if (previousApi === undefined)
+ delete process.env.NEXT_PUBLIC_LEADERBOARD_API_URL;
+ else process.env.NEXT_PUBLIC_LEADERBOARD_API_URL = previousApi;
+ vi.resetModules();
+ }
+ });
+
+ it("API path falls back to events when API responds non-OK", async () => {
+ const previousApi = process.env.NEXT_PUBLIC_LEADERBOARD_API_URL;
+ process.env.NEXT_PUBLIC_LEADERBOARD_API_URL =
+ "https://leaderboard.example/rankings";
+ vi.resetModules();
+ const { SorobanService: ApiSorobanService } = await import("./soroban");
+ const service = new ApiSorobanService();
+ vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: false,
+ status: 503,
+ } as Response);
+ const rpcServer = makeMockRpcServer({
+ getLatestLedger: vi.fn().mockResolvedValue({ sequence: 200_000 }),
+ getEvents: vi.fn().mockResolvedValue({
+ events: [
+ makeContractEvent({
+ action: "lock_assets",
+ address: USER_PUBLIC_KEY,
+ value: { amount: 250_000_000n },
+ }),
+ ],
+ }),
+ });
+ internals(service as SorobanService).rpcServer = rpcServer;
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ vi.spyOn(service, "getFactoryPools").mockResolvedValue([
+ {
+ id: "factory-pool",
+ contractAddress: POOL_CONTRACT_ID,
+ asset: { code: "XLM", isNative: true },
+ dailyRate: "0",
+ minLockPeriod: 0,
+ totalLocked: "0",
+ totalUsers: 0,
+ isActive: true,
+ createdAt: 1,
+ },
+ ]);
+
+ try {
+ await expect(
+ service.getUserRank(USER_PUBLIC_KEY, "credits"),
+ ).resolves.toBe(1);
+ } finally {
+ if (previousApi === undefined)
+ delete process.env.NEXT_PUBLIC_LEADERBOARD_API_URL;
+ else process.env.NEXT_PUBLIC_LEADERBOARD_API_URL = previousApi;
+ vi.resetModules();
+ }
+ });
+
+ it("events path returns correct rank when user is on a different page", async () => {
+ const otherUser = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 8));
+ const thirdUser = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 9));
+ const { service, rpcServer } = makeService({ pool: false });
+ vi.spyOn(service, "getFactoryPools").mockResolvedValue([
+ {
+ id: "factory-pool",
+ contractAddress: POOL_CONTRACT_ID,
+ asset: { code: "XLM", isNative: true },
+ dailyRate: "0",
+ minLockPeriod: 0,
+ totalLocked: "0",
+ totalUsers: 0,
+ isActive: true,
+ createdAt: 1,
+ },
+ ]);
+ rpcServer.getLatestLedger.mockResolvedValue({ sequence: 200_000 });
+ rpcServer.getEvents.mockResolvedValue({
+ events: [
+ // user1 (otherUser) — rank #1 by credits (120 credits)
+ makeContractEvent({
+ action: "lock_assets",
+ address: otherUser,
+ value: { amount: 100_000_000n },
+ }),
+ makeContractEvent({
+ action: "update_credits",
+ address: otherUser,
+ value: { credits: 120 },
+ }),
+ // user2 (USER_PUBLIC_KEY) — rank #2 by credits (80 credits)
+ makeContractEvent({
+ action: "lock_assets",
+ address: USER_PUBLIC_KEY,
+ value: { amount: 300_000_000n },
+ }),
+ makeContractEvent({
+ action: "update_credits",
+ address: USER_PUBLIC_KEY,
+ value: { credits: 80 },
+ }),
+ // user3 (thirdUser) — rank #3 by credits (50 credits)
+ makeContractEvent({
+ action: "update_credits",
+ address: thirdUser,
+ value: { credits: 50 },
+ }),
+ makeContractEvent({
+ action: "lock_assets",
+ address: thirdUser,
+ value: { amount: 50_000_000n },
+ }),
+ ],
+ });
+
+ // USER_PUBLIC_KEY has 80 credits → rank #2 by credits
+ await expect(
+ service.getUserRank(USER_PUBLIC_KEY, "credits"),
+ ).resolves.toBe(2);
+
+ // otherUser has 120 credits → rank #1 by credits
+ await expect(
+ service.getUserRank(otherUser, "credits"),
+ ).resolves.toBe(1);
+
+ // thirdUser has 50 credits → rank #3 by credits
+ await expect(
+ service.getUserRank(thirdUser, "credits"),
+ ).resolves.toBe(3);
+ });
+
+ it("events path returns null when user has no on-chain activity", async () => {
+ const noHistoryUser = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 10));
+ const { service, rpcServer } = makeService({ pool: false });
+ vi.spyOn(service, "getFactoryPools").mockResolvedValue([
+ {
+ id: "factory-pool",
+ contractAddress: POOL_CONTRACT_ID,
+ asset: { code: "XLM", isNative: true },
+ dailyRate: "0",
+ minLockPeriod: 0,
+ totalLocked: "0",
+ totalUsers: 0,
+ isActive: true,
+ createdAt: 1,
+ },
+ ]);
+ rpcServer.getLatestLedger.mockResolvedValue({ sequence: 200_000 });
+ rpcServer.getEvents.mockResolvedValue({ events: [] });
+
+ await expect(
+ service.getUserRank(noHistoryUser, "credits"),
+ ).resolves.toBeNull();
+ });
+
+ it("events path returns null when RPC errors", async () => {
+ const warnSpy = vi
+ .spyOn(console, "warn")
+ .mockImplementation(() => undefined);
+ const { service, rpcServer } = makeService({ pool: false });
+ vi.spyOn(service, "getFactoryPools").mockResolvedValue([
+ {
+ id: "factory-pool",
+ contractAddress: POOL_CONTRACT_ID,
+ asset: { code: "XLM", isNative: true },
+ dailyRate: "0",
+ minLockPeriod: 0,
+ totalLocked: "0",
+ totalUsers: 0,
+ isActive: true,
+ createdAt: 1,
+ },
+ ]);
+ rpcServer.getLatestLedger.mockRejectedValue(
+ new Error("RPC unavailable"),
+ );
+
+ await expect(
+ service.getUserRank(USER_PUBLIC_KEY, "stake"),
+ ).resolves.toBeNull();
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ "[SmartDrop] rank-from-events failed:",
+ expect.any(Error),
+ );
+ });
+ });
});
describe("soroban exported utilities and transaction history", () => {
diff --git a/src/lib/soroban.ts b/src/lib/soroban.ts
index 0810187..791ec35 100644
--- a/src/lib/soroban.ts
+++ b/src/lib/soroban.ts
@@ -1319,11 +1319,14 @@ export class SorobanService {
const dailyMap = new Map();
let runningTvl = 0;
- // Seed today and past N days so chart always has points
+ // Seed today and past N days using pure UTC date arithmetic
+ // (avoids local-timezone Date.setDate which produces keys that disagree
+ // with the UTC dateKeys derived from ledgerClosedAt).
+ const dayMs = 86_400_000;
+ const todayUtcMidnight = Math.floor(Date.now() / dayMs) * dayMs;
for (let i = days - 1; i >= 0; i--) {
- const d = new Date();
- d.setDate(d.getDate() - i);
- dailyMap.set(d.toISOString().slice(0, 10), 0);
+ const key = new Date(todayUtcMidnight - i * dayMs).toISOString().slice(0, 10);
+ dailyMap.set(key, 0);
}
for (const evt of response.events) {
@@ -1438,6 +1441,124 @@ export class SorobanService {
return this.fetchLeaderboardFromEvents(offset, limit, sortKey);
}
+ /**
+ * Resolve the connected user's global rank (1-indexed) independent of pagination.
+ *
+ * Returns the 1-based rank if the address has any on-chain activity, or null
+ * if the address has never participated (no rank at all).
+ *
+ * Prefers the backend indexer when LEADERBOARD_API_URL is configured;
+ * otherwise computes the rank by scanning all relevant on-chain events.
+ */
+ async getUserRank(
+ address: string,
+ sortKey: LeaderboardSortKey = 'credits',
+ ): Promise {
+ if (!address) return null;
+
+ if (LEADERBOARD_API_URL) {
+ try {
+ return await this.fetchUserRankFromApi(address, sortKey);
+ } catch (err) {
+ console.warn('[SmartDrop] rank API failed, falling back to event scan:', err);
+ }
+ }
+
+ return this.fetchUserRankFromEvents(address, sortKey);
+ }
+
+ private async fetchUserRankFromApi(
+ address: string,
+ sortKey: LeaderboardSortKey,
+ ): Promise {
+ const url = new URL(LEADERBOARD_API_URL.replace(/\/+$/, '') + '/rank');
+ url.searchParams.set('address', address);
+ url.searchParams.set('sort', sortKey);
+
+ const res = await fetch(url.toString(), { headers: { accept: 'application/json' } });
+ if (!res.ok) throw new Error(`Rank API responded ${res.status}`);
+
+ const data = (await res.json()) as { rank?: number | null };
+ return data.rank != null ? Number(data.rank) : null;
+ }
+
+ private async fetchUserRankFromEvents(
+ address: string,
+ sortKey: LeaderboardSortKey,
+ ): Promise {
+ const poolIds = await this.getLeaderboardPoolIds();
+ if (poolIds.length === 0) return null;
+
+ try {
+ const latest = await this.rpcServer.getLatestLedger();
+ const startLedger = Math.max(1, latest.sequence - LEADERBOARD_LOOKBACK_LEDGERS);
+
+ const lockSym = xdr.ScVal.scvSymbol('lock_assets').toXDR('base64');
+ const unlockSym = xdr.ScVal.scvSymbol('unlock_assets').toXDR('base64');
+ const creditSym = xdr.ScVal.scvSymbol('update_credits').toXDR('base64');
+
+ const response = await this.rpcServer.getEvents({
+ startLedger,
+ filters: [
+ {
+ type: 'contract',
+ contractIds: poolIds,
+ topics: [[lockSym, '*'], [unlockSym, '*'], [creditSym, '*']],
+ },
+ ],
+ limit: 1000,
+ });
+
+ const agg = new Map();
+ const rowFor = (addr: string) => {
+ let row = agg.get(addr);
+ if (!row) {
+ row = { stake: 0, credits: 0 };
+ agg.set(addr, row);
+ }
+ return row;
+ };
+
+ for (const evt of response.events) {
+ if (!evt.inSuccessfulContractCall) continue;
+ const topics = (evt.topic as xdr.ScVal[]).map(scValToNative);
+ const action = topics[0] as string;
+ const addr = String(topics[1] ?? '');
+ if (!addr) continue;
+
+ const valueNative = scValToNative(evt.value as xdr.ScVal);
+ const amount = this.extractEventAmount(valueNative);
+ const row = rowFor(addr);
+
+ if (action === 'lock_assets') row.stake += amount / 10_000_000;
+ else if (action === 'unlock_assets') row.stake = Math.max(0, row.stake - amount / 10_000_000);
+ else if (action === 'update_credits') row.credits = amount;
+ }
+
+ // Build full sorted list
+ const all = Array.from(agg.entries())
+ .filter(([addr]) => addr.length > 0)
+ .map(([addr, { stake, credits }]) => ({
+ address: addr,
+ totalCredits: Math.round(credits),
+ totalStake: Math.round(stake),
+ }))
+ .filter((e) => e.totalStake > 0 || e.totalCredits > 0);
+
+ all.sort((a, b) =>
+ sortKey === 'credits'
+ ? b.totalCredits - a.totalCredits
+ : b.totalStake - a.totalStake,
+ );
+
+ const idx = all.findIndex((e) => e.address === address);
+ return idx === -1 ? null : idx + 1;
+ } catch (err) {
+ console.warn('[SmartDrop] rank-from-events failed:', err);
+ return null;
+ }
+ }
+
private async fetchLeaderboardFromApi(
offset: number,
limit: number,