Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { predictionsRouter } from "./routes/predictions";
import { usersRouter } from "./routes/users";
import { usersHealthRouter } from "./routes/users/health";
import { userPortfolioRouter } from "./routes/users/portfolio";
import { userStatsRouter } from "./routes/users/stats";
import { devicesRouter } from "./routes/devices";
import { adminFeatureFlagsRouter } from "./routes/admin/featureFlags";
import { adminUsersRouter } from "./routes/adminUsers";
Expand Down Expand Up @@ -163,6 +164,7 @@ export function createApp(_options: CreateAppOptions = {}): express.Express {
app.use("/api/users/health", usersHealthRouter);
app.use("/api/users", socialRouter);
app.use("/api/users", userPortfolioRouter);
app.use("/api/users", userStatsRouter);
app.use("/api/users", usersRouter);
app.use("/api/me/devices", devicesRouter);
app.use("/api/me/sessions", sessionsRouter);
Expand Down
24 changes: 24 additions & 0 deletions src/routes/users/stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Router, Request, Response, NextFunction } from "express";
import { z } from "zod";
import { getUserStats } from "../../services/userStatsService";

export const userStatsRouter = Router();

const stellarAddressSchema = z.string().regex(/^G[A-Z2-7]{55}$/, "Invalid Stellar address");

userStatsRouter.get("/:addr/stats", async (req: Request, res: Response, next: NextFunction) => {
const parsed = stellarAddressSchema.safeParse(req.params.addr);
if (!parsed.success) {
return res.status(400).json({ error: { code: "invalid_address" } });
}

try {
const stats = await getUserStats(parsed.data);
if (!stats) {
return res.status(404).json({ error: { code: "not_found" } });
}
return res.json({ data: stats });
} catch (error) {
return next(error);
}
});
111 changes: 111 additions & 0 deletions src/services/userStatsService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { eq } from "drizzle-orm";
import { getDb } from "../db/client";
import { claims, predictions, users } from "../db/schema";
import { getRequestId } from "../lib/requestContext";
import { logger } from "../config/logger";

const CACHE_TTL_MS = 30_000;

type CacheEntry = { expiresAt: number; value: UserStats };
const cache = new Map<string, CacheEntry>();

export interface UserStats {
address: string;
totalPredictions: number;
totalStaked: string;
marketsParticipated: number;
byStatus: {
won: number;
lost: number;
pending: number;
confirmed: number;
claimed: number;
};
totalClaimed: string;
winRate: number;
cachedAt: string;
}

function addDecimalStrings(a: string, b: string): string {
if (!a && !b) return "0";
if (!a || !/^\d+$/.test(a)) return b && /^\d+$/.test(b) ? b : "0";
if (!b || !/^\d+$/.test(b)) return a;
return (BigInt(a) + BigInt(b)).toString();
}

export function clearUserStatsCache(): void {
cache.clear();
}

export async function getUserStats(address: string): Promise<UserStats | null> {
const now = Date.now();
const cached = cache.get(address);
if (cached && cached.expiresAt > now) return cached.value;

const db = getDb();
const userRows = await db
.select({ id: users.id, stellarAddress: users.stellarAddress })
.from(users)
.where(eq(users.stellarAddress, address))
.limit(1);
const user = userRows[0];
if (!user) return null;

const [predictionRows, claimRows] = await Promise.all([
db
.select({
id: predictions.id,
marketId: predictions.marketId,
amount: predictions.amount,
status: predictions.status,
})
.from(predictions)
.where(eq(predictions.userId, user.id)),
db
.select({ amount: claims.amount })
.from(claims)
.where(eq(claims.userId, user.id)),
]);

const byStatus = { won: 0, lost: 0, pending: 0, confirmed: 0, claimed: 0 };
let totalStaked = "0";
const markets = new Set<string>();

for (const row of predictionRows) {
totalStaked = addDecimalStrings(totalStaked, row.amount);
markets.add(row.marketId);

const key = row.status as keyof typeof byStatus;
if (key in byStatus) {
byStatus[key] += 1;
}
}

let totalClaimed = "0";
for (const row of claimRows) {
totalClaimed = addDecimalStrings(totalClaimed, row.amount);
}

const resolvedTotal = byStatus.won + byStatus.lost;
const winRate = resolvedTotal > 0 ? byStatus.won / resolvedTotal : 0;

const stats: UserStats = {
address: user.stellarAddress,
totalPredictions: predictionRows.length,
totalStaked,
marketsParticipated: markets.size,
byStatus,
totalClaimed,
winRate,
cachedAt: new Date(now).toISOString(),
};

cache.set(address, { value: stats, expiresAt: now + CACHE_TTL_MS });

logger.info(
{ reqId: getRequestId(), address, totalPredictions: predictionRows.length, winRate },
"Computed user stats",
);

return stats;
}
73 changes: 73 additions & 0 deletions tests/userStats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
process.env.NODE_ENV = "test";
process.env.LOG_LEVEL = "fatal";
process.env.DATABASE_URL = "postgres://localhost/test";
process.env.JWT_SECRET = "stats-test-secret-at-least-32-bytes";
process.env.STELLAR_NETWORK = "testnet";
process.env.SOROBAN_RPC_URL = "https://soroban-testnet.stellar.org";
process.env.HORIZON_URL = "https://horizon-testnet.stellar.org";
process.env.PREDICTIFY_CONTRACT_ID = "CABCDEF";

jest.mock("ioredis", () => jest.fn().mockImplementation(() => ({ on: jest.fn(), ping: jest.fn() })));
jest.mock("bullmq", () => ({ Queue: jest.fn().mockImplementation(() => ({ on: jest.fn(), add: jest.fn(), close: jest.fn() })) }));

import request from "supertest";
import { createApp } from "../src/index";
import { getUserStats } from "../src/services/userStatsService";

jest.mock("../src/services/userStatsService", () => ({
getUserStats: jest.fn(),
}));

const mockGetUserStats = getUserStats as jest.MockedFunction<typeof getUserStats>;
const ADDRESS = `G${"A".repeat(55)}`;

describe("GET /api/users/:addr/stats", () => {
beforeEach(() => {
mockGetUserStats.mockReset();
});

it("returns per-user aggregated stats", async () => {
mockGetUserStats.mockResolvedValue({
address: ADDRESS,
totalPredictions: 42,
totalStaked: "15000",
marketsParticipated: 8,
byStatus: { won: 12, lost: 5, pending: 20, confirmed: 3, claimed: 2 },
totalClaimed: "5000",
winRate: 0.71,
cachedAt: "2026-07-27T00:00:00.000Z",
});

const res = await request(createApp()).get(`/api/users/${ADDRESS}/stats`);

expect(res.status).toBe(200);
expect(res.body.data).toEqual(
expect.objectContaining({
address: ADDRESS,
totalPredictions: 42,
totalStaked: "15000",
marketsParticipated: 8,
winRate: 0.71,
}),
);
expect(res.body.data.byStatus).toEqual({ won: 12, lost: 5, pending: 20, confirmed: 3, claimed: 2 });
expect(mockGetUserStats).toHaveBeenCalledWith(ADDRESS);
});

it("rejects invalid addresses", async () => {
const res = await request(createApp()).get("/api/users/not-a-wallet/stats");

expect(res.status).toBe(400);
expect(res.body).toEqual({ error: { code: "invalid_address" } });
expect(mockGetUserStats).not.toHaveBeenCalled();
});

it("returns 404 for unknown users", async () => {
mockGetUserStats.mockResolvedValue(null);

const res = await request(createApp()).get(`/api/users/${ADDRESS}/stats`);

expect(res.status).toBe(404);
expect(res.body).toEqual({ error: { code: "not_found" } });
});
});
102 changes: 102 additions & 0 deletions tests/userStatsService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { getUserStats, clearUserStatsCache } from "../src/services/userStatsService";

const select = jest.fn();

jest.mock("../src/db/client", () => ({
getDb: () => ({ select }),
}));

const ADDRESS = `G${"A".repeat(55)}`;

function selectReturning(rows: unknown[], needsLimit = false) {
return {
from: jest.fn(() => ({
where: jest.fn(() =>
needsLimit
? { limit: jest.fn(async () => rows) }
: Promise.resolve(rows),
),
})),
};
}

describe("getUserStats", () => {
beforeEach(() => {
clearUserStatsCache();
select.mockReset();
});

it("aggregates predictions by status and computes win rate", async () => {
select
.mockReturnValueOnce(selectReturning([{ id: "user-1", stellarAddress: ADDRESS }], true))
.mockReturnValueOnce(selectReturning([
{ id: "p1", marketId: "mkt-1", amount: "100", status: "won" },
{ id: "p2", marketId: "mkt-1", amount: "50", status: "lost" },
{ id: "p3", marketId: "mkt-2", amount: "25", status: "pending" },
{ id: "p4", marketId: "mkt-3", amount: "75", status: "confirmed" },
{ id: "p5", marketId: "mkt-3", amount: "10", status: "claimed" },
]))
.mockReturnValueOnce(selectReturning([{ amount: "200" }]));

const stats = await getUserStats(ADDRESS);

expect(stats).not.toBeNull();
expect(stats!.totalPredictions).toBe(5);
expect(stats!.totalStaked).toBe("260");
expect(stats!.marketsParticipated).toBe(3);
expect(stats!.totalClaimed).toBe("200");
expect(stats!.byStatus).toEqual({ won: 1, lost: 1, pending: 1, confirmed: 1, claimed: 1 });
expect(stats!.winRate).toBe(0.5);
});

it("returns winRate 0 when no resolved predictions", async () => {
select
.mockReturnValueOnce(selectReturning([{ id: "user-1", stellarAddress: ADDRESS }], true))
.mockReturnValueOnce(selectReturning([
{ id: "p1", marketId: "mkt-1", amount: "10", status: "pending" },
{ id: "p2", marketId: "mkt-1", amount: "20", status: "pending" },
]))
.mockReturnValueOnce(selectReturning([]));

const stats = await getUserStats(ADDRESS);

expect(stats!.winRate).toBe(0);
expect(stats!.totalStaked).toBe("30");
});

it("returns winRate 1 when all won", async () => {
select
.mockReturnValueOnce(selectReturning([{ id: "user-1", stellarAddress: ADDRESS }], true))
.mockReturnValueOnce(selectReturning([
{ id: "p1", marketId: "mkt-1", amount: "100", status: "won" },
{ id: "p2", marketId: "mkt-2", amount: "50", status: "won" },
]))
.mockReturnValueOnce(selectReturning([]));

const stats = await getUserStats(ADDRESS);

expect(stats!.winRate).toBe(1);
expect(stats!.byStatus).toEqual({ won: 2, lost: 0, pending: 0, confirmed: 0, claimed: 0 });
});

it("returns null for unknown address", async () => {
select
.mockReturnValueOnce(selectReturning([], true));

const stats = await getUserStats(ADDRESS);

expect(stats).toBeNull();
});

it("uses the short-lived cache for repeated reads", async () => {
select
.mockReturnValueOnce(selectReturning([{ id: "user-1", stellarAddress: ADDRESS }], true))
.mockReturnValueOnce(selectReturning([]))
.mockReturnValueOnce(selectReturning([]));

await getUserStats(ADDRESS);
await getUserStats(ADDRESS);

expect(select).toHaveBeenCalledTimes(3);
});
});