From eba95f9c189ed19e6b7b576eb5a13644c4b0d75b Mon Sep 17 00:00:00 2001 From: DioChuks Date: Thu, 22 Oct 2026 12:41:07 +0100 Subject: [PATCH 1/4] feat: added `key_leaderboards` model & migration --- prisma/schema/key-leaderboard.prisma | 22 +++++++++++++ .../migration.sql | 33 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 prisma/schema/key-leaderboard.prisma create mode 100644 prisma/schema/migrations/20261022122500_add_key_leaderboards/migration.sql diff --git a/prisma/schema/key-leaderboard.prisma b/prisma/schema/key-leaderboard.prisma new file mode 100644 index 00000000..612fd274 --- /dev/null +++ b/prisma/schema/key-leaderboard.prisma @@ -0,0 +1,22 @@ +// Current per-wallet key leaderboard metrics, indexed from LeaderboardUpdated +// contract events. The ledger/event index watermark makes replays and +// out-of-order delivery converge on the latest snapshot. +model KeyLeaderboard { + id String @id @default(cuid()) + /// Key ID is not a foreign key so events preceding key registration are retained. + creatorId String + wallet String + volume BigInt + pnl BigInt + holdingDays BigInt + ledger Int + eventIndex Int + txHash String + updatedAt DateTime @updatedAt + + @@unique([creatorId, wallet]) + @@index([creatorId, volume(sort: Desc), wallet], map: "key_leaderboards_creatorId_volume_idx") + @@index([creatorId, pnl(sort: Desc), wallet], map: "key_leaderboards_creatorId_pnl_idx") + @@index([creatorId, holdingDays(sort: Desc), wallet], map: "key_leaderboards_creatorId_holdingDays_idx") + @@map("key_leaderboards") +} diff --git a/prisma/schema/migrations/20261022122500_add_key_leaderboards/migration.sql b/prisma/schema/migrations/20261022122500_add_key_leaderboards/migration.sql new file mode 100644 index 00000000..185d3d11 --- /dev/null +++ b/prisma/schema/migrations/20261022122500_add_key_leaderboards/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE "key_leaderboards" ( + "id" TEXT NOT NULL, + "creatorId" TEXT NOT NULL, + "wallet" TEXT NOT NULL, + "volume" BIGINT NOT NULL, + "pnl" BIGINT NOT NULL, + "holdingDays" BIGINT NOT NULL, + "ledger" INTEGER NOT NULL, + "eventIndex" INTEGER NOT NULL, + "txHash" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "key_leaderboards_pkey" PRIMARY KEY ("id"), + CONSTRAINT "key_leaderboards_volume_check" CHECK ("volume" >= 0), + CONSTRAINT "key_leaderboards_holding_days_check" CHECK ("holdingDays" >= 0) +); + +-- CreateIndex +CREATE UNIQUE INDEX "key_leaderboards_creatorId_wallet_key" +ON "key_leaderboards"("creatorId", "wallet"); + +-- CreateIndex +CREATE INDEX "key_leaderboards_creatorId_volume_idx" +ON "key_leaderboards"("creatorId", "volume" DESC, "wallet"); + +-- CreateIndex +CREATE INDEX "key_leaderboards_creatorId_pnl_idx" +ON "key_leaderboards"("creatorId", "pnl" DESC, "wallet"); + +-- CreateIndex +CREATE INDEX "key_leaderboards_creatorId_holdingDays_idx" +ON "key_leaderboards"("creatorId", "holdingDays" DESC, "wallet"); From d9b055480f6ba63c32d8098df57c6a029a393df5 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Thu, 22 Oct 2026 12:42:25 +0100 Subject: [PATCH 2/4] feat: added replay-and-ordering-safe event indexing with malformed events sent to the DLQ --- .../indexer/indexer-pipeline.service.ts | 11 + .../key-leaderboard-indexer.service.test.ts | 190 +++++++++++++++ .../key-leaderboard-indexer.service.ts | 216 ++++++++++++++++++ .../indexer/twap-pipeline-dispatch.test.ts | 54 +++++ 4 files changed, 471 insertions(+) create mode 100644 src/modules/indexer/key-leaderboard-indexer.service.test.ts create mode 100644 src/modules/indexer/key-leaderboard-indexer.service.ts diff --git a/src/modules/indexer/indexer-pipeline.service.ts b/src/modules/indexer/indexer-pipeline.service.ts index 5ba3176c..a51e0b27 100644 --- a/src/modules/indexer/indexer-pipeline.service.ts +++ b/src/modules/indexer/indexer-pipeline.service.ts @@ -423,6 +423,17 @@ export async function processIndexerEvents( await processDepthUpdatedEvents(depthEvents); } + // LeaderboardUpdated events. Await before advancing the shared + // cursor so a failed write causes the event batch to be redelivered. + const leaderboardEvents = events.filter(event => + ['LEADERBOARD_UPDATED', 'LeaderboardUpdated'].includes(event.eventType) + ); + if (leaderboardEvents.length > 0) { + const { processLeaderboardUpdatedEvents } = + await import('./key-leaderboard-indexer.service'); + await processLeaderboardUpdatedEvents(leaderboardEvents); + } + const whitelistEvents = events.filter( event => event.eventType === 'WalletWhitelisted' || diff --git a/src/modules/indexer/key-leaderboard-indexer.service.test.ts b/src/modules/indexer/key-leaderboard-indexer.service.test.ts new file mode 100644 index 00000000..778fde26 --- /dev/null +++ b/src/modules/indexer/key-leaderboard-indexer.service.test.ts @@ -0,0 +1,190 @@ +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + keyLeaderboard: { + updateMany: jest.fn(), + create: jest.fn(), + }, + }, +})); +jest.mock('../../utils/redis.utils', () => ({ + cacheInvalidate: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('../../utils/indexer-dlq.utils', () => ({ + moveToDLQ: jest.fn().mockResolvedValue({}), +})); +jest.mock('../../utils/logger.utils', () => ({ + logger: { + warn: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, +})); + +import { prisma } from '../../utils/prisma.utils'; +import { cacheInvalidate } from '../../utils/redis.utils'; +import { moveToDLQ } from '../../utils/indexer-dlq.utils'; +import { + parseLeaderboardUpdatedEvent, + processLeaderboardUpdatedEvent, + processLeaderboardUpdatedEvents, + KEY_LEADERBOARD_INDEXER_DLQ_JOB_TYPE, +} from './key-leaderboard-indexer.service'; +import { IndexerChainEvent } from '../../utils/indexer-event-processor.utils'; + +const mockTable = ( + prisma as unknown as { + keyLeaderboard: { updateMany: jest.Mock; create: jest.Mock }; + } +).keyLeaderboard; +const mockInvalidate = cacheInvalidate as jest.Mock; +const mockDlq = moveToDLQ as jest.Mock; + +function leaderboardEvent( + overrides: Record = {} +): IndexerChainEvent { + return { + txHash: 'tx-1', + eventIndex: 0, + eventType: 'LeaderboardUpdated', + ledger: 100, + creatorId: 'key-1', + wallet: 'GABC', + volume: '100000', + pnl: '-250', + holdingDays: '12', + ...overrides, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockTable.updateMany.mockResolvedValue({ count: 0 }); + mockTable.create.mockResolvedValue({}); +}); + +describe('parseLeaderboardUpdatedEvent', () => { + it('parses exact integer scores and event aliases', () => { + expect( + parseLeaderboardUpdatedEvent( + leaderboardEvent({ + eventType: 'LEADERBOARD_UPDATED', + key_id: 'key-2', + creatorId: undefined, + wallet_address: 'GWALLET', + wallet: undefined, + holding_days: '25', + holdingDays: undefined, + volume: '9007199254740993', + }) + ) + ).toEqual({ + ok: true, + value: { + creatorId: 'key-2', + wallet: 'GWALLET', + volume: 9007199254740993n, + pnl: -250n, + holdingDays: 25n, + ledger: 100, + eventIndex: 0, + txHash: 'tx-1', + }, + }); + }); + + it.each([ + ['missing key', { creatorId: '' }], + ['missing wallet', { wallet: '' }], + ['negative volume', { volume: '-1' }], + ['invalid pnl', { pnl: 'unknown' }], + ['negative holding days', { holdingDays: '-1' }], + ['missing ledger', { ledger: undefined }], + ['missing event identity', { txHash: '' }], + ])('rejects %s', (_name, overrides) => { + expect(parseLeaderboardUpdatedEvent(leaderboardEvent(overrides)).ok).toBe( + false + ); + }); +}); + +describe('processLeaderboardUpdatedEvent', () => { + it('upserts a new snapshot and invalidates the key cache', async () => { + await expect( + processLeaderboardUpdatedEvent(leaderboardEvent()) + ).resolves.toBe(true); + + expect(mockTable.create).toHaveBeenCalledWith({ + data: { + creatorId: 'key-1', + wallet: 'GABC', + volume: 100000n, + pnl: -250n, + holdingDays: 12n, + ledger: 100, + eventIndex: 0, + txHash: 'tx-1', + }, + }); + expect(mockInvalidate).toHaveBeenCalledWith('key:leaderboard:key-1:*'); + }); + + it('overwrites only with a newer on-chain position', async () => { + mockTable.updateMany.mockResolvedValueOnce({ count: 1 }); + + await expect( + processLeaderboardUpdatedEvent(leaderboardEvent({ ledger: 101 })) + ).resolves.toBe(true); + + expect(mockTable.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + creatorId: 'key-1', + wallet: 'GABC', + OR: [ + { ledger: { lt: 101 } }, + { ledger: 101, eventIndex: { lt: 0 } }, + ], + }, + }) + ); + expect(mockTable.create).not.toHaveBeenCalled(); + }); + + it('moves malformed events to the DLQ', async () => { + await processLeaderboardUpdatedEvent( + leaderboardEvent({ holdingDays: '-2' }) + ); + + expect(mockDlq).toHaveBeenCalledWith( + expect.objectContaining({ + jobType: KEY_LEADERBOARD_INDEXER_DLQ_JOB_TYPE, + retryCount: 0, + }) + ); + expect(mockTable.create).not.toHaveBeenCalled(); + expect(mockInvalidate).not.toHaveBeenCalled(); + }); + + it('propagates persistence errors so the pipeline cursor stays put', async () => { + mockTable.create.mockRejectedValueOnce(new Error('database unavailable')); + + await expect( + processLeaderboardUpdatedEvent(leaderboardEvent()) + ).rejects.toThrow('database unavailable'); + expect(mockInvalidate).not.toHaveBeenCalled(); + }); + + it('processes supported event spellings and ignores unrelated events', async () => { + await processLeaderboardUpdatedEvents([ + leaderboardEvent({ txHash: 'first' }), + leaderboardEvent({ txHash: 'ignored', eventType: 'KEY_BOUGHT' }), + leaderboardEvent({ + txHash: 'second', + eventType: 'LEADERBOARD_UPDATED', + }), + ]); + + expect(mockTable.create).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/modules/indexer/key-leaderboard-indexer.service.ts b/src/modules/indexer/key-leaderboard-indexer.service.ts new file mode 100644 index 00000000..6866e33c --- /dev/null +++ b/src/modules/indexer/key-leaderboard-indexer.service.ts @@ -0,0 +1,216 @@ +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { + processIndexerChainEvents, + IndexerChainEvent, +} from '../../utils/indexer-event-processor.utils'; +import { moveToDLQ } from '../../utils/indexer-dlq.utils'; +import { invalidateKeyLeaderboardCache } from '../keys/key-event-leaderboard.service'; + +export const LEADERBOARD_UPDATED_EVENT_TYPES: ReadonlySet = new Set([ + 'LEADERBOARD_UPDATED', + 'LeaderboardUpdated', +]); + +export const KEY_LEADERBOARD_INDEXER_DLQ_JOB_TYPE = 'LEADERBOARD_UPDATED'; + +export interface ParsedLeaderboardUpdate { + creatorId: string; + wallet: string; + volume: bigint; + pnl: bigint; + holdingDays: bigint; + ledger: number; + eventIndex: number; + txHash: string; +} + +export type LeaderboardParseResult = + | { ok: true; value: ParsedLeaderboardUpdate } + | { ok: false; reason: string }; + +function firstPresent( + event: Record, + names: readonly string[] +): unknown { + for (const name of names) { + if (event[name] !== undefined && event[name] !== null) { + return event[name]; + } + } + return undefined; +} + +function parseInteger(value: unknown): bigint | null { + if ( + (typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'bigint') || + (typeof value === 'string' && value.trim() === '') || + (typeof value === 'number' && !Number.isSafeInteger(value)) + ) { + return null; + } + try { + return BigInt(value); + } catch { + return null; + } +} + +export function parseLeaderboardUpdatedEvent( + event: IndexerChainEvent +): LeaderboardParseResult { + const raw = event as Record; + const creatorValue = firstPresent(raw, ['creatorId', 'keyId', 'key_id']); + const walletValue = firstPresent(raw, [ + 'wallet', + 'walletAddress', + 'wallet_address', + ]); + const volume = parseInteger(firstPresent(raw, ['volume'])); + const pnl = parseInteger(firstPresent(raw, ['pnl'])); + const holdingDays = parseInteger( + firstPresent(raw, ['holdingDays', 'holding_days']) + ); + const ledger = Number(event.ledger); + const eventIndex = Number(event.eventIndex); + + if (typeof creatorValue !== 'string' || creatorValue.trim() === '') { + return { ok: false, reason: 'missing or invalid key ID' }; + } + if (typeof walletValue !== 'string' || walletValue.trim() === '') { + return { ok: false, reason: 'missing or invalid wallet' }; + } + if (volume === null || volume < 0n) { + return { ok: false, reason: 'missing or invalid volume' }; + } + if (pnl === null) { + return { ok: false, reason: 'missing or invalid pnl' }; + } + if (holdingDays === null || holdingDays < 0n) { + return { ok: false, reason: 'missing or invalid holding days' }; + } + if (!Number.isInteger(ledger) || ledger < 0) { + return { ok: false, reason: 'missing or invalid ledger' }; + } + if ( + typeof event.txHash !== 'string' || + event.txHash === '' || + !Number.isInteger(eventIndex) || + eventIndex < 0 + ) { + return { ok: false, reason: 'missing or invalid txHash/eventIndex' }; + } + + return { + ok: true, + value: { + creatorId: creatorValue.trim(), + wallet: walletValue.trim(), + volume, + pnl, + holdingDays, + ledger, + eventIndex, + txHash: event.txHash, + }, + }; +} + +async function applyLeaderboardUpdate( + update: ParsedLeaderboardUpdate +): Promise { + const identity = { + creatorId: update.creatorId, + wallet: update.wallet, + }; + const newerThanStored = { + ...identity, + OR: [ + { ledger: { lt: update.ledger } }, + { ledger: update.ledger, eventIndex: { lt: update.eventIndex } }, + ], + }; + const data = { + volume: update.volume, + pnl: update.pnl, + holdingDays: update.holdingDays, + ledger: update.ledger, + eventIndex: update.eventIndex, + txHash: update.txHash, + }; + + const updated = await prisma.keyLeaderboard.updateMany({ + where: newerThanStored, + data, + }); + if (updated.count > 0) return true; + + try { + await prisma.keyLeaderboard.create({ data: { ...identity, ...data } }); + return true; + } catch (error) { + if ((error as { code?: string })?.code !== 'P2002') throw error; + } + + const retried = await prisma.keyLeaderboard.updateMany({ + where: newerThanStored, + data, + }); + return retried.count > 0; +} + +export async function processLeaderboardUpdatedEvent( + event: IndexerChainEvent +): Promise { + const eventId = `${event.txHash}:${event.eventIndex}`; + const parsed = parseLeaderboardUpdatedEvent(event); + + if (!parsed.ok) { + logger.warn( + { type: 'leaderboard_event_rejected', eventId, reason: parsed.reason }, + 'Malformed LeaderboardUpdated event moved to DLQ' + ); + await moveToDLQ({ + jobType: KEY_LEADERBOARD_INDEXER_DLQ_JOB_TYPE, + payload: JSON.parse( + JSON.stringify(event, (_key, value) => + typeof value === 'bigint' ? value.toString() : value + ) + ), + retryCount: 0, + failureReason: parsed.reason, + }); + return false; + } + + const update = parsed.value; + const changed = await applyLeaderboardUpdate(update); + await invalidateKeyLeaderboardCache(update.creatorId); + + logger.debug( + { + type: changed + ? 'key_leaderboard_indexed' + : 'key_leaderboard_stale_skipped', + eventId, + creator_id: update.creatorId, + wallet: update.wallet, + ledger: update.ledger, + }, + changed + ? 'Key leaderboard entry indexed' + : 'Stale LeaderboardUpdated event skipped' + ); + return changed; +} + +export async function processLeaderboardUpdatedEvents( + events: IndexerChainEvent[] +): Promise { + await processIndexerChainEvents(events, async event => { + if (!LEADERBOARD_UPDATED_EVENT_TYPES.has(event.eventType)) return; + await processLeaderboardUpdatedEvent(event); + }); +} diff --git a/src/modules/indexer/twap-pipeline-dispatch.test.ts b/src/modules/indexer/twap-pipeline-dispatch.test.ts index e24e14c3..6f0085c2 100644 --- a/src/modules/indexer/twap-pipeline-dispatch.test.ts +++ b/src/modules/indexer/twap-pipeline-dispatch.test.ts @@ -37,6 +37,10 @@ jest.mock('../../utils/prisma.utils', () => ({ activity: { create: jest.fn() }, indexedLedger: { upsert: jest.fn() }, twapSnapshot: { create: jest.fn() }, + keyLeaderboard: { + updateMany: jest.fn(), + create: jest.fn(), + }, }, })); jest.mock('../../utils/logger.utils', () => ({ @@ -55,6 +59,7 @@ import { prisma } from '../../utils/prisma.utils'; const mockPrisma = prisma as unknown as { indexedLedger: { upsert: jest.Mock }; twapSnapshot: { create: jest.Mock }; + keyLeaderboard: { updateMany: jest.Mock; create: jest.Mock }; }; const twap = (txHash: string, ledger: number) => ({ @@ -74,6 +79,8 @@ describe('processIndexerEvents with TWAPUpdated', () => { jest.clearAllMocks(); mockPrisma.indexedLedger.upsert.mockResolvedValue({}); mockPrisma.twapSnapshot.create.mockResolvedValue({}); + mockPrisma.keyLeaderboard.updateMany.mockResolvedValue({ count: 0 }); + mockPrisma.keyLeaderboard.create.mockResolvedValue({}); }); it('indexes TWAP events and advances the cursor to the batch max ledger', async () => { @@ -102,4 +109,51 @@ describe('processIndexerEvents with TWAPUpdated', () => { await processIndexerEvents([twap('a', 300)]); expect(mockPrisma.indexedLedger.upsert).toHaveBeenCalledTimes(1); }); + + it('indexes LeaderboardUpdated events before advancing the cursor', async () => { + await processIndexerEvents([ + { + txHash: 'leaderboard-tx', + eventIndex: 1, + eventType: 'LeaderboardUpdated', + ledger: 305, + creatorId: 'key-1', + wallet: 'GABC', + volume: '1000', + pnl: '20', + holdingDays: '5', + }, + ]); + + expect(mockPrisma.keyLeaderboard.create).toHaveBeenCalledTimes(1); + expect(mockPrisma.indexedLedger.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ ledger: 305 }), + }) + ); + }); + + it('does not advance the cursor if leaderboard persistence fails', async () => { + mockPrisma.keyLeaderboard.create.mockRejectedValueOnce( + new Error('db down') + ); + + await expect( + processIndexerEvents([ + { + txHash: 'leaderboard-tx', + eventIndex: 1, + eventType: 'LeaderboardUpdated', + ledger: 306, + creatorId: 'key-1', + wallet: 'GABC', + volume: '1000', + pnl: '20', + holdingDays: '5', + }, + ]) + ).rejects.toThrow('db down'); + + expect(mockPrisma.indexedLedger.upsert).not.toHaveBeenCalled(); + }); }); From 14f0e90c40af1e2471e7c0d9f4e3cf189f40180f Mon Sep 17 00:00:00 2001 From: DioChuks Date: Thu, 22 Oct 2026 12:43:38 +0100 Subject: [PATCH 3/4] feat: added per-key metric & wallet-rank endpoints --- .../keys/key-event-leaderboard.controller.ts | 71 ++++++++++ .../keys/key-event-leaderboard.service.ts | 134 ++++++++++++++++++ .../keys/key-leaderboard.integration.test.ts | 133 ++++++++++++++++- src/modules/keys/keys.routes.ts | 16 +++ 4 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 src/modules/keys/key-event-leaderboard.controller.ts create mode 100644 src/modules/keys/key-event-leaderboard.service.ts diff --git a/src/modules/keys/key-event-leaderboard.controller.ts b/src/modules/keys/key-event-leaderboard.controller.ts new file mode 100644 index 00000000..17b04d11 --- /dev/null +++ b/src/modules/keys/key-event-leaderboard.controller.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; +import { AsyncController } from '../../types/auth.types'; +import { + sendSuccess, + sendValidationError, + zodIssuesToDetails, +} from '../../utils/api-response.utils'; +import { safeIntParam } from '../../utils/query.utils'; +import { + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE, +} from '../../constants/pagination.constants'; +import { + getKeyEventLeaderboard, + getWalletKeyLeaderboard, + KEY_LEADERBOARD_METRICS, +} from './key-event-leaderboard.service'; + +const leaderboardQuerySchema = z.object({ + metric: z.enum(KEY_LEADERBOARD_METRICS), + limit: safeIntParam({ + defaultValue: DEFAULT_PAGE_SIZE, + min: 1, + max: MAX_PAGE_SIZE, + label: 'Limit', + }), +}); + +export const httpGetKeyEventLeaderboard: AsyncController = async ( + req, + res, + next +) => { + const parsed = leaderboardQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid query parameters', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + + try { + const { metric, limit } = parsed.data; + const items = await getKeyEventLeaderboard( + String(req.params.keyId), + metric, + limit + ); + sendSuccess(res, { items }); + } catch (error) { + next(error); + } +}; + +export const httpGetWalletKeyEventLeaderboard: AsyncController = async ( + req, + res, + next +) => { + try { + const result = await getWalletKeyLeaderboard( + String(req.params.keyId), + String(req.params.wallet) + ); + sendSuccess(res, result); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/keys/key-event-leaderboard.service.ts b/src/modules/keys/key-event-leaderboard.service.ts new file mode 100644 index 00000000..dfe4581d --- /dev/null +++ b/src/modules/keys/key-event-leaderboard.service.ts @@ -0,0 +1,134 @@ +import { prisma } from '../../utils/prisma.utils'; +import { + cacheGetJson, + cacheInvalidate, + cacheSetJson, +} from '../../utils/redis.utils'; + +export const KEY_LEADERBOARD_CACHE_TTL_SECONDS = 60; +export const KEY_LEADERBOARD_METRICS = [ + 'volume', + 'pnl', + 'holding_days', +] as const; + +export type KeyLeaderboardMetric = (typeof KEY_LEADERBOARD_METRICS)[number]; +type MetricField = 'volume' | 'pnl' | 'holdingDays'; + +const METRIC_FIELDS: Record = { + volume: 'volume', + pnl: 'pnl', + holding_days: 'holdingDays', +}; + +export interface KeyLeaderboardEntry { + rank: number; + wallet: string; + score: string; +} + +export interface WalletLeaderboardMetric { + rank: number; + score: string; +} + +export interface WalletKeyLeaderboard { + keyId: string; + wallet: string; + metrics: Record; +} + +export const keyLeaderboardCacheKey = (creatorId: string): string => + `key:leaderboard:${creatorId}:`; + +function listCacheKey( + creatorId: string, + metric: KeyLeaderboardMetric, + limit: number +): string { + return `${keyLeaderboardCacheKey(creatorId)}${metric}:${limit}`; +} + +export async function invalidateKeyLeaderboardCache( + creatorId: string +): Promise { + await cacheInvalidate(`${keyLeaderboardCacheKey(creatorId)}*`); +} + +export async function getKeyEventLeaderboard( + creatorId: string, + metric: KeyLeaderboardMetric, + limit: number +): Promise { + const cacheKey = listCacheKey(creatorId, metric, limit); + const cached = await cacheGetJson(cacheKey); + if (cached) return cached; + + const field = METRIC_FIELDS[metric]; + const rows = await prisma.keyLeaderboard.findMany({ + where: { creatorId }, + orderBy: [{ [field]: 'desc' }, { wallet: 'asc' }], + take: limit, + select: { + wallet: true, + volume: true, + pnl: true, + holdingDays: true, + }, + }); + const items = rows.map((row, index) => ({ + rank: index + 1, + wallet: row.wallet, + score: String(row[field]), + })); + + await cacheSetJson(cacheKey, items, KEY_LEADERBOARD_CACHE_TTL_SECONDS); + return items; +} + +export async function getWalletKeyLeaderboard( + creatorId: string, + wallet: string +): Promise { + const cacheKey = `${keyLeaderboardCacheKey(creatorId)}wallet:${wallet}`; + const cached = await cacheGetJson(cacheKey); + if (cached) return cached; + + const row = await prisma.keyLeaderboard.findUnique({ + where: { creatorId_wallet: { creatorId, wallet } }, + select: { + wallet: true, + volume: true, + pnl: true, + holdingDays: true, + }, + }); + + const metrics = {} as WalletKeyLeaderboard['metrics']; + for (const metric of KEY_LEADERBOARD_METRICS) { + const field = METRIC_FIELDS[metric]; + if (!row) { + metrics[metric] = null; + continue; + } + + const score = row[field]; + const aheadCount = await prisma.keyLeaderboard.count({ + where: { + creatorId, + OR: [ + { [field]: { gt: score } }, + { [field]: score, wallet: { lt: wallet } }, + ], + }, + }); + metrics[metric] = { + rank: aheadCount + 1, + score: String(score), + }; + } + + const result = { keyId: creatorId, wallet, metrics }; + await cacheSetJson(cacheKey, result, KEY_LEADERBOARD_CACHE_TTL_SECONDS); + return result; +} diff --git a/src/modules/keys/key-leaderboard.integration.test.ts b/src/modules/keys/key-leaderboard.integration.test.ts index 8cd51b32..cf254ae8 100644 --- a/src/modules/keys/key-leaderboard.integration.test.ts +++ b/src/modules/keys/key-leaderboard.integration.test.ts @@ -2,7 +2,7 @@ import supertest from 'supertest'; import app from '../../app'; import { prisma } from '../../utils/prisma.utils'; -import { getRedis } from '../../utils/redis.utils'; +import { cacheSetJson, getRedis } from '../../utils/redis.utils'; jest.mock('../../utils/prisma.utils', () => ({ prisma: { @@ -15,18 +15,31 @@ jest.mock('../../utils/prisma.utils', () => ({ activity: { findMany: jest.fn(), }, + keyLeaderboard: { + findMany: jest.fn(), + findUnique: jest.fn(), + count: jest.fn(), + }, $disconnect: jest.fn(), }, })); jest.mock('../../utils/redis.utils', () => ({ getRedis: jest.fn(), + cacheGetJson: jest.fn().mockResolvedValue(null), + cacheSetJson: jest.fn().mockResolvedValue(undefined), + cacheInvalidate: jest.fn().mockResolvedValue(undefined), })); const mockPrisma = prisma as unknown as { creatorProfile: { findMany: jest.Mock }; keyOwnership: { groupBy: jest.Mock }; activity: { findMany: jest.Mock }; + keyLeaderboard: { + findMany: jest.Mock; + findUnique: jest.Mock; + count: jest.Mock; + }; }; const mockGetRedis = getRedis as jest.Mock; @@ -89,6 +102,9 @@ describe('GET /api/v1/keys/leaderboard', () => { payload: { amount: '50', price_at_trade: '10' }, // volume = 500 }, ]); + mockPrisma.keyLeaderboard.findMany.mockResolvedValue([]); + mockPrisma.keyLeaderboard.findUnique.mockResolvedValue(null); + mockPrisma.keyLeaderboard.count.mockResolvedValue(0); }); it('defaults to sort_by=holder_count and breaks ties by creation date ascending', async () => { @@ -169,4 +185,119 @@ describe('GET /api/v1/keys/leaderboard', () => { expect(res.body.data.items[0].keyId).toBe('cached-key'); expect(mockRedisClient.get).toHaveBeenCalled(); }); + + it('returns an empty list for a key with no indexed leaderboard rows', async () => { + const res = await supertest(app).get( + '/api/v1/keys/key-empty/leaderboard?metric=volume' + ); + + expect(res.status).toBe(200); + expect(res.body.data.items).toEqual([]); + expect(mockPrisma.keyLeaderboard.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { creatorId: 'key-empty' }, + take: 20, + }) + ); + }); + + it('ranks per-key leaderboard entries by the requested metric', async () => { + mockPrisma.keyLeaderboard.findMany.mockResolvedValue([ + { wallet: 'GAAA', volume: 900n, pnl: 3n, holdingDays: 1n }, + { wallet: 'GBBB', volume: 800n, pnl: 10n, holdingDays: 5n }, + ]); + + const res = await supertest(app).get( + '/api/v1/keys/key-1/leaderboard?metric=volume&limit=2' + ); + + expect(res.status).toBe(200); + expect(res.body.data.items).toEqual([ + { rank: 1, wallet: 'GAAA', score: '900' }, + { rank: 2, wallet: 'GBBB', score: '800' }, + ]); + expect(mockPrisma.keyLeaderboard.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: [{ volume: 'desc' }, { wallet: 'asc' }], + take: 2, + }) + ); + expect(cacheSetJson).toHaveBeenCalledWith( + 'key:leaderboard:key-1:volume:2', + res.body.data.items, + 60 + ); + }); + + it.each([ + ['volume', 'volume'], + ['pnl', 'pnl'], + ['holding_days', 'holdingDays'], + ])('supports the %s ranking metric', async (metric, field) => { + mockPrisma.keyLeaderboard.findMany.mockResolvedValue([ + { wallet: 'GAAA', volume: 9n, pnl: 3n, holdingDays: 1n }, + ]); + + const res = await supertest(app).get( + `/api/v1/keys/key-1/leaderboard?metric=${metric}` + ); + + expect(res.status).toBe(200); + expect(res.body.data.items).toEqual([ + { + rank: 1, + wallet: 'GAAA', + score: metric === 'volume' ? '9' : metric === 'pnl' ? '3' : '1', + }, + ]); + expect(mockPrisma.keyLeaderboard.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: [{ [field]: 'desc' }, { wallet: 'asc' }], + }) + ); + }); + + it('returns rank and score for each metric using the same tie ordering', async () => { + mockPrisma.keyLeaderboard.findUnique.mockResolvedValue({ + wallet: 'GABC', + volume: 100n, + pnl: -5n, + holdingDays: 20n, + }); + mockPrisma.keyLeaderboard.count + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(1); + + const res = await supertest(app).get( + '/api/v1/keys/key-1/leaderboard/GABC' + ); + + expect(res.status).toBe(200); + expect(res.body.data.metrics).toEqual({ + volume: { rank: 3, score: '100' }, + pnl: { rank: 1, score: '-5' }, + holding_days: { rank: 2, score: '20' }, + }); + expect(mockPrisma.keyLeaderboard.count).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: { + creatorId: 'key-1', + OR: [ + { volume: { gt: 100n } }, + { volume: 100n, wallet: { lt: 'GABC' } }, + ], + }, + }) + ); + }); + + it('rejects an unsupported per-key leaderboard metric', async () => { + const res = await supertest(app).get( + '/api/v1/keys/key-1/leaderboard?metric=holders' + ); + + expect(res.status).toBe(400); + }); }); diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index 934b9b7d..1ce5077a 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -38,6 +38,10 @@ import { envConfig } from '../../config'; import { getKeyProposals, getProposalForVoting } from './key-proposals.service'; import { getKeySupply } from './key-supply.service'; import { httpGetKeyLeaderboard } from './key-leaderboard.controller'; +import { + httpGetKeyEventLeaderboard, + httpGetWalletKeyEventLeaderboard, +} from './key-event-leaderboard.controller'; import { httpGetHolderLeaderboard, httpGetHolderRank, @@ -689,6 +693,18 @@ router.get('/:keyId/holders/leaderboard', httpGetHolderLeaderboard); */ router.get('/:keyId/holders/:wallet/rank', httpGetHolderRank); +/** + * GET /api/v1/keys/:keyId/leaderboard?metric=volume|pnl|holding_days&limit= + * Returns indexed wallet rankings for a key and metric. + */ +router.get('/:keyId/leaderboard', httpGetKeyEventLeaderboard); + +/** + * GET /api/v1/keys/:keyId/leaderboard/:wallet + * Returns the wallet's rank and score for each indexed metric. + */ +router.get('/:keyId/leaderboard/:wallet', httpGetWalletKeyEventLeaderboard); + /** * GET /api/v1/keys/:keyId/circuit-breaker?limit=&offset= * From b4d15ec729418a4ecf45fbdf8a1fa01cf06f1387 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Thu, 22 Oct 2026 12:43:58 +0100 Subject: [PATCH 4/4] doc: updated endpoints --- docs/api-endpoints.md | 24 +++++++++++++++++++++++- docs/api-inventory.md | 10 ++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/api-endpoints.md b/docs/api-endpoints.md index d6b25bd1..ace47fe5 100644 --- a/docs/api-endpoints.md +++ b/docs/api-endpoints.md @@ -740,7 +740,9 @@ Cached for 60 seconds. "success": true, "data": { "total_balance": "1200000000", - "per_source_breakdown": [{ "source": "trading", "balance": "1500000000" }], + "per_source_breakdown": [ + { "source": "trading", "balance": "1500000000" } + ], "last_updated": "2026-10-21T10:00:00.000Z" } } @@ -766,6 +768,26 @@ Paginated `FeeReceived` history (`source`, `amount`, `tx_hash`, `ledger`, ## Keys Endpoints +### GET /keys/:keyId/leaderboard + +Returns wallets ranked by the selected `LeaderboardUpdated` metric. Results +are cached for 60 seconds and the cache is invalidated when a new event is +indexed for the key. Keys without indexed leaderboard rows return an empty +`items` array. + +- **Auth:** None +- **Query Params:** `metric` (`volume`, `pnl`, or `holding_days`, required), + `limit` (integer, default `20`, maximum `100`) +- **Response:** `{ "success": true, "data": { "items": [{ "rank": 1, "wallet": "...", "score": "..." }] } }` + +### GET /keys/:keyId/leaderboard/:wallet + +Returns the wallet's ordinal rank and exact score for each leaderboard metric. +Each metric is `null` when the wallet has no indexed entry for the key. + +- **Auth:** None +- **Response:** `{ "success": true, "data": { "keyId": "...", "wallet": "...", "metrics": { "volume": { "rank": 1, "score": "..." }, "pnl": null, "holding_days": null } } }` + ### GET /keys/:keyId/price Returns the current bonding-curve buy and sell price for a given quantity of diff --git a/docs/api-inventory.md b/docs/api-inventory.md index f2b87a96..9a82b5e5 100644 --- a/docs/api-inventory.md +++ b/docs/api-inventory.md @@ -194,6 +194,16 @@ Early-access whitelist status check and management endpoints (#1084). | `POST` | `/keys/:keyId/whitelist` | Bulk add wallets to key whitelist (creator JWT required). | | `DELETE` | `/keys/:keyId/whitelist/:wallet` | Remove a wallet from key whitelist (creator JWT required). | +## Key Leaderboard + +Wallet rankings are indexed from `LeaderboardUpdated` contract events and +cached for 60 seconds. The cache is invalidated when a new event is indexed. + +| Method | Path | Description | +| :----- | :----------------------------------------- | :----------------------------------------------------- | +| `GET` | `/keys/:key_id/leaderboard?metric=&limit=` | Ranked wallets for `volume`, `pnl`, or `holding_days`. | +| `GET` | `/keys/:key_id/leaderboard/:wallet` | Wallet rank and score for all three metrics. | + --- ## Root and Miscellaneous