From d0479514231e31791bd560b68b769404ceaa8c56 Mon Sep 17 00:00:00 2001 From: Samuel-21-eng Date: Tue, 29 Sep 2026 17:07:21 -0800 Subject: [PATCH] feat(keys): build leaderboard endpoint for top creator keys (#941) --- .../keys/key-leaderboard.controller.ts | 54 ++++ .../keys/key-leaderboard.integration.test.ts | 172 ++++++++++++ src/modules/keys/key-leaderboard.service.ts | 246 ++++++++++++++++++ src/modules/keys/keys.routes.ts | 7 + 4 files changed, 479 insertions(+) create mode 100644 src/modules/keys/key-leaderboard.controller.ts create mode 100644 src/modules/keys/key-leaderboard.integration.test.ts create mode 100644 src/modules/keys/key-leaderboard.service.ts diff --git a/src/modules/keys/key-leaderboard.controller.ts b/src/modules/keys/key-leaderboard.controller.ts new file mode 100644 index 0000000..e52b1ff --- /dev/null +++ b/src/modules/keys/key-leaderboard.controller.ts @@ -0,0 +1,54 @@ +// src/modules/keys/key-leaderboard.controller.ts +import { AsyncController } from '../../types/auth.types'; +import { getKeyLeaderboard } from './key-leaderboard.service'; +import { + sendSuccess, + sendValidationError, + zodIssuesToDetails, +} from '../../utils/api-response.utils'; +import { attachTimestampHeader } from '../../utils/timestamp-headers.utils'; +import { z } from 'zod'; +import { safeIntParam } from '../../utils/query.utils'; +import { + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE, +} from '../../constants/pagination.constants'; + +const leaderboardQuerySchema = z.object({ + sort_by: z + .enum(['holder_count', 'volume_24h', 'volume_7d', 'price_change'] as const) + .optional() + .default('holder_count'), + limit: safeIntParam({ + defaultValue: DEFAULT_PAGE_SIZE, + min: 1, + max: MAX_PAGE_SIZE, + label: 'Limit', + }), +}); + +export const httpGetKeyLeaderboard: AsyncController = async ( + req, + res, + next +) => { + try { + const parsed = leaderboardQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid query parameters', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + + const { sort_by, limit } = parsed.data; + const items = await getKeyLeaderboard(sort_by, limit); + + attachTimestampHeader(res); + sendSuccess(res, { items }); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/keys/key-leaderboard.integration.test.ts b/src/modules/keys/key-leaderboard.integration.test.ts new file mode 100644 index 0000000..8cd51b3 --- /dev/null +++ b/src/modules/keys/key-leaderboard.integration.test.ts @@ -0,0 +1,172 @@ +// src/modules/keys/key-leaderboard.integration.test.ts +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; +import { getRedis } from '../../utils/redis.utils'; + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + creatorProfile: { + findMany: jest.fn(), + }, + keyOwnership: { + groupBy: jest.fn(), + }, + activity: { + findMany: jest.fn(), + }, + $disconnect: jest.fn(), + }, +})); + +jest.mock('../../utils/redis.utils', () => ({ + getRedis: jest.fn(), +})); + +const mockPrisma = prisma as unknown as { + creatorProfile: { findMany: jest.Mock }; + keyOwnership: { groupBy: jest.Mock }; + activity: { findMany: jest.Mock }; +}; + +const mockGetRedis = getRedis as jest.Mock; + +describe('GET /api/v1/keys/leaderboard', () => { + const now = new Date('2026-06-01T12:00:00Z'); + const olderDate = new Date('2026-05-01T12:00:00Z'); + const newerDate = new Date('2026-05-15T12:00:00Z'); + + const creators = [ + { + id: 'key-1', + handle: 'alice', + displayName: 'Alice', + avatarUrl: null, + createdAt: olderDate, + priceSnapshot: { currentPrice: 200n, price24hAgo: 100n }, + }, + { + id: 'key-2', + handle: 'bob', + displayName: 'Bob', + avatarUrl: null, + createdAt: newerDate, + priceSnapshot: { currentPrice: 150n, price24hAgo: 100n }, + }, + { + id: 'key-3', + handle: 'charlie', + displayName: 'Charlie', + avatarUrl: null, + createdAt: now, + priceSnapshot: { currentPrice: 300n, price24hAgo: 100n }, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetRedis.mockReturnValue(null); + mockPrisma.creatorProfile.findMany.mockResolvedValue(creators); + mockPrisma.keyOwnership.groupBy.mockResolvedValue([ + { creatorId: 'key-1', _count: { ownerAddress: 10 } }, + { creatorId: 'key-2', _count: { ownerAddress: 10 } }, // Tied holder count with key-1 + { creatorId: 'key-3', _count: { ownerAddress: 5 } }, + ]); + mockPrisma.activity.findMany.mockResolvedValue([ + { + creatorId: 'key-1', + createdAt: new Date(), + payload: { amount: '100', price_at_trade: '10' }, // volume = 1000 + }, + { + creatorId: 'key-2', + createdAt: new Date(), + payload: { amount: '200', price_at_trade: '10' }, // volume = 2000 + }, + { + creatorId: 'key-3', + createdAt: new Date(), + payload: { amount: '50', price_at_trade: '10' }, // volume = 500 + }, + ]); + }); + + it('defaults to sort_by=holder_count and breaks ties by creation date ascending', async () => { + const res = await supertest(app).get('/api/v1/keys/leaderboard'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + const items = res.body.data.items; + expect(items).toHaveLength(3); + + // key-1 (10 holders, olderDate) and key-2 (10 holders, newerDate) tie on holders. + // key-1 should win tie-break due to older createdAt. + expect(items[0].keyId).toBe('key-1'); + expect(items[1].keyId).toBe('key-2'); + expect(items[2].keyId).toBe('key-3'); + expect(items[0].rank).toBe(1); + expect(items[1].rank).toBe(2); + expect(items[2].rank).toBe(3); + }); + + it('sorts correctly by volume_24h', async () => { + const res = await supertest(app).get('/api/v1/keys/leaderboard?sort_by=volume_24h'); + + expect(res.status).toBe(200); + const items = res.body.data.items; + expect(items).toHaveLength(3); + // key-2 volume = 2000, key-1 volume = 1000, key-3 volume = 500 + expect(items[0].keyId).toBe('key-2'); + expect(items[1].keyId).toBe('key-1'); + expect(items[2].keyId).toBe('key-3'); + }); + + it('sorts correctly by price_change', async () => { + const res = await supertest(app).get('/api/v1/keys/leaderboard?sort_by=price_change'); + + expect(res.status).toBe(200); + const items = res.body.data.items; + expect(items).toHaveLength(3); + // key-3 price change = +200%, key-1 = +100%, key-2 = +50% + expect(items[0].keyId).toBe('key-3'); + expect(items[1].keyId).toBe('key-1'); + expect(items[2].keyId).toBe('key-2'); + }); + + it('respects limit parameter and rejects invalid limit with 400', async () => { + const resValid = await supertest(app).get('/api/v1/keys/leaderboard?limit=2'); + expect(resValid.status).toBe(200); + expect(resValid.body.data.items).toHaveLength(2); + + const resInvalid = await supertest(app).get('/api/v1/keys/leaderboard?limit=101'); + expect(resInvalid.status).toBe(400); + }); + + it('uses Redis cache when available', async () => { + const mockRedisClient = { + get: jest.fn().mockResolvedValue( + JSON.stringify([ + { + rank: 1, + keyId: 'cached-key', + creatorName: 'Cached', + handle: 'cached', + avatarUrl: null, + holder_count: 99, + volume_24h: '0', + volume_7d: '0', + price_change: 0, + metricValue: 99, + }, + ]) + ), + set: jest.fn().mockResolvedValue('OK'), + }; + mockGetRedis.mockReturnValue(mockRedisClient); + + const res = await supertest(app).get('/api/v1/keys/leaderboard'); + expect(res.status).toBe(200); + expect(res.body.data.items[0].keyId).toBe('cached-key'); + expect(mockRedisClient.get).toHaveBeenCalled(); + }); +}); diff --git a/src/modules/keys/key-leaderboard.service.ts b/src/modules/keys/key-leaderboard.service.ts new file mode 100644 index 0000000..3e9ef52 --- /dev/null +++ b/src/modules/keys/key-leaderboard.service.ts @@ -0,0 +1,246 @@ +// src/modules/keys/key-leaderboard.service.ts +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { getRedis } from '../../utils/redis.utils'; +import { compute24hPriceChange } from '../../utils/price.utils'; +import { MAX_PAGE_SIZE } from '../../constants/pagination.constants'; + +export type KeyLeaderboardSortBy = + | 'holder_count' + | 'volume_24h' + | 'volume_7d' + | 'price_change'; + +export interface KeyLeaderboardEntry { + rank: number; + keyId: string; + creatorName: string; + handle: string; + avatarUrl: string | null; + holder_count: number; + volume_24h: string; + volume_7d: string; + price_change: number | null; + metricValue: number | string; +} + +const REDIS_OP_TIMEOUT_MS = 1000; +const CACHE_TTL_SECONDS = 60; + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Redis operation timed out after ${ms}ms`)), + ms + ); + promise.then( + value => { + clearTimeout(timer); + resolve(value); + }, + error => { + clearTimeout(timer); + reject(error); + } + ); + }); +} + +export async function computeKeyLeaderboard( + sortBy: KeyLeaderboardSortBy, + limit: number +): Promise { + const now = Date.now(); + const nowDate = new Date(now); + const window7dStart = new Date(now - 7 * 24 * 60 * 60 * 1000); + const window24hStart = new Date(now - 24 * 60 * 60 * 1000); + + const creators = await prisma.creatorProfile.findMany({ + select: { + id: true, + handle: true, + displayName: true, + avatarUrl: true, + createdAt: true, + priceSnapshot: { + select: { currentPrice: true, price24hAgo: true }, + }, + }, + }); + + if (creators.length === 0) { + return []; + } + + const creatorIds = creators.map(c => c.id); + + const holderCountsByCreator = new Map(); + const holderGroups = await prisma.keyOwnership.groupBy({ + by: ['creatorId'], + where: { + creatorId: { in: creatorIds }, + balance: { gt: 0 }, + }, + _count: { + ownerAddress: true, + }, + }); + for (const g of holderGroups) { + holderCountsByCreator.set(g.creatorId, g._count.ownerAddress); + } + + const activities = await prisma.activity.findMany({ + where: { + type: { in: ['KEY_BOUGHT', 'KEY_SOLD'] }, + creatorId: { in: creatorIds }, + createdAt: { gte: window7dStart, lte: nowDate }, + }, + select: { creatorId: true, createdAt: true, payload: true }, + }); + + const volume24hMap = new Map(); + const volume7dMap = new Map(); + + for (const activity of activities) { + if (!activity.creatorId) continue; + const payload = activity.payload as Record; + if ( + payload && + payload.amount !== undefined && + payload.price_at_trade !== undefined && + payload.price_at_trade !== null + ) { + try { + const tradeVolume = + BigInt(Math.trunc(Number(payload.amount))) * + BigInt(payload.price_at_trade as string | number); + + volume7dMap.set( + activity.creatorId, + (volume7dMap.get(activity.creatorId) ?? 0n) + tradeVolume + ); + + if (activity.createdAt >= window24hStart) { + volume24hMap.set( + activity.creatorId, + (volume24hMap.get(activity.creatorId) ?? 0n) + tradeVolume + ); + } + } catch { + continue; + } + } + } + + const unranked = creators.map(creator => { + const holderCount = holderCountsByCreator.get(creator.id) ?? 0; + const volume24h = volume24hMap.get(creator.id) ?? 0n; + const volume7d = volume7dMap.get(creator.id) ?? 0n; + const snapshot = creator.priceSnapshot; + const priceChange = snapshot + ? compute24hPriceChange(snapshot.currentPrice, snapshot.price24hAgo) + : 0; + + let metricValue: number | string = 0; + if (sortBy === 'holder_count') metricValue = holderCount; + else if (sortBy === 'volume_24h') metricValue = volume24h.toString(); + else if (sortBy === 'volume_7d') metricValue = volume7d.toString(); + else if (sortBy === 'price_change') metricValue = priceChange ?? 0; + + return { + keyId: creator.id, + creatorName: creator.displayName, + handle: creator.handle, + avatarUrl: creator.avatarUrl, + createdAt: creator.createdAt, + holder_count: holderCount, + volume_24h: volume24h.toString(), + volume_7d: volume7d.toString(), + price_change: priceChange, + metricValue, + numericMetric: + sortBy === 'volume_24h' + ? Number(volume24h) + : sortBy === 'volume_7d' + ? Number(volume7d) + : Number(metricValue), + }; + }); + + unranked.sort((a, b) => { + const diff = b.numericMetric - a.numericMetric; + if (diff !== 0) { + return diff > 0 ? 1 : -1; + } + // Tied primary metric: secondary sort by creation date ascending (earlier created first) + const timeDiff = a.createdAt.getTime() - b.createdAt.getTime(); + if (timeDiff !== 0) { + return timeDiff; + } + // Tertiary tie-breaker: keyId ascending + return a.keyId < b.keyId ? -1 : a.keyId > b.keyId ? 1 : 0; + }); + + return unranked.slice(0, limit).map((entry, index) => ({ + rank: index + 1, + keyId: entry.keyId, + creatorName: entry.creatorName, + handle: entry.handle, + avatarUrl: entry.avatarUrl, + holder_count: entry.holder_count, + volume_24h: entry.volume_24h, + volume_7d: entry.volume_7d, + price_change: entry.price_change, + metricValue: entry.metricValue, + })); +} + +export async function getKeyLeaderboard( + sortBy: KeyLeaderboardSortBy, + limit: number +): Promise { + const redis = getRedis(); + const cacheKey = `leaderboard:keys:${sortBy}:v1`; + + if (redis) { + try { + const cached = await withTimeout( + redis.get(cacheKey), + REDIS_OP_TIMEOUT_MS + ); + if (cached) { + const allCached = JSON.parse(cached) as KeyLeaderboardEntry[]; + return allCached.slice(0, limit); + } + } catch (error) { + logger.warn( + { error, sortBy }, + 'Key leaderboard cache read failed; computing live' + ); + } + } + + // Compute up to MAX_PAGE_SIZE (100) so caching covers all requested limit values up to max + const leaderboard = await computeKeyLeaderboard(sortBy, MAX_PAGE_SIZE); + + if (redis) { + try { + await withTimeout( + redis.set( + cacheKey, + JSON.stringify(leaderboard), + 'EX', + CACHE_TTL_SECONDS + ), + REDIS_OP_TIMEOUT_MS + ); + } catch (error) { + logger.warn( + { error, sortBy }, + 'Key leaderboard cache write failed; serving uncached result' + ); + } + } + + return leaderboard.slice(0, limit); +} diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index 1cf0e50..8877004 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -33,6 +33,7 @@ import { cacheControl } from '../../middlewares/cache-control.middleware'; import { envConfig } from '../../config'; import { getKeyProposals, getProposalForVoting } from './key-proposals.service'; import { getKeySupply } from './key-supply.service'; +import { httpGetKeyLeaderboard } from './key-leaderboard.controller'; import { KeySearchQueryTooShortError, searchKeys } from './key-search.service'; import { KEY_SEARCH_MIN_QUERY_LENGTH } from '../../constants/notifications.constants'; @@ -300,6 +301,12 @@ router.post('/batch', async (req, res, next) => { } }); +/** + * GET /api/v1/keys/leaderboard + * Ranks creator keys by holder count, trading volume, or price performance. + */ +router.get('/leaderboard', httpGetKeyLeaderboard); + /** * GET /api/v1/keys/search?q= * Full-text search over creator name and description.