From 956a390855c61b959c96f12bad3a0653075c052c Mon Sep 17 00:00:00 2001 From: KingDavid9999 Date: Sat, 26 Sep 2026 00:29:16 -0700 Subject: [PATCH 1/3] feat(lp): track LP allocations per key and add TVL reporting endpoint (#943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record protocol-owned liquidity from bonding-curve buys and expose it via per-key and protocol-wide endpoints. - Add LpAllocation model (creatorId, amountXlm, ledger, txHash, eventIndex, allocatedAt) with a (txHash, eventIndex) unique constraint for idempotent event replay. - Add processLpAllocationEvents indexer handler for the LPAllocationSent contract event; validates required fields, skips duplicates, and invalidates the affected key's and admin's caches. - Add GET /keys/:keyId/lp-stats — total LP contributed and current LP balance per key, cached 60s. - Add GET /keys/:keyId/lp-history?limit=&cursor= — cursor-paginated LP contribution history, newest first. - Add GET /admin/lp-overview — protocol-owned liquidity aggregated across all keys, cached 60s, admin-only. - Add integration tests covering event recording, idempotency, missing-field handling, per-key stats, admin aggregation, cache invalidation, and pagination. Note: no "LP removed" contract event exists in scope yet, so currentLpBalanceXlm currently equals totalLpContributedXlm. Closes #943 --- prisma/schema/lp-allocation.prisma | 24 ++ src/modules/admin/admin.routes.ts | 17 ++ src/modules/admin/lp-overview.service.ts | 62 ++++ .../indexer/lp-allocation-indexer.service.ts | 96 +++++++ .../indexer/lp-allocation.integration.test.ts | 271 ++++++++++++++++++ src/modules/keys/key-lp.service.ts | 134 +++++++++ src/modules/keys/keys.routes.ts | 54 ++++ 7 files changed, 658 insertions(+) create mode 100644 prisma/schema/lp-allocation.prisma create mode 100644 src/modules/admin/lp-overview.service.ts create mode 100644 src/modules/indexer/lp-allocation-indexer.service.ts create mode 100644 src/modules/indexer/lp-allocation.integration.test.ts create mode 100644 src/modules/keys/key-lp.service.ts diff --git a/prisma/schema/lp-allocation.prisma b/prisma/schema/lp-allocation.prisma new file mode 100644 index 00000000..82403bf2 --- /dev/null +++ b/prisma/schema/lp-allocation.prisma @@ -0,0 +1,24 @@ +// prisma/schema/lp-allocation.prisma +// Liquidity-pool allocations sent from bonding-curve buys, recorded from the +// LPAllocationSent contract event. Used to report cumulative LP contributed +// per key and total protocol-owned liquidity across all keys (#943). + +/// One LPAllocationSent contract event. Append-only: the protocol only +/// ever sends LP into the pool from buys in the current scope (no +/// corresponding "LP removed" event exists yet), so cumulative amount +/// contributed and current LP balance are the same figure for now. +model LpAllocation { + id String @id @default(cuid()) + creatorId String + amountXlm Decimal @db.Decimal(20, 7) + ledger Int + txHash String + eventIndex Int + allocatedAt DateTime + createdAt DateTime @default(now()) + + @@unique([txHash, eventIndex]) + @@index([creatorId]) + @@index([allocatedAt(sort: Desc)]) + @@index([ledger]) +} diff --git a/src/modules/admin/admin.routes.ts b/src/modules/admin/admin.routes.ts index c3257fb1..051c5e01 100644 --- a/src/modules/admin/admin.routes.ts +++ b/src/modules/admin/admin.routes.ts @@ -15,6 +15,7 @@ import { analyticsWindowQuerySchema, getPlatformAnalytics, } from '../keys/key-analytics.service'; +import { getProtocolLpOverview } from './lp-overview.service'; import { adminGuard, AdminRequest, @@ -140,6 +141,22 @@ adminRouter.get('/analytics', adminGuard, async (req: AdminRequest, res, next) = } }); +/** + * GET /api/v1/admin/lp-overview + * + * Protocol-owned liquidity across all keys: total LP XLM contributed, the + * number of keys holding LP, and the number of allocation events. Sourced + * from LPAllocationSent contract events. Cached 60s (#943). + */ +adminRouter.get('/lp-overview', adminGuard, async (req: AdminRequest, res, next) => { + try { + sendSuccess(res, await getProtocolLpOverview()); + } catch (error) { + logger.error({ error }, 'LP overview failed'); + next(error); + } +}); + /** * GET /api/v1/admin/keys/:keyId/snapshot * diff --git a/src/modules/admin/lp-overview.service.ts b/src/modules/admin/lp-overview.service.ts new file mode 100644 index 00000000..d4477397 --- /dev/null +++ b/src/modules/admin/lp-overview.service.ts @@ -0,0 +1,62 @@ +// src/modules/admin/lp-overview.service.ts +// Protocol-owned liquidity across all keys, aggregated from LpAllocation +// rows recorded by the LP allocation indexer (#943). Cached 60s; invalidated +// on every new LPAllocationSent event. +import { prisma } from '../../utils/prisma.utils'; +import { + cacheGetJson, + cacheInvalidate, + cacheSetJson, +} from '../../utils/redis.utils'; + +export const LP_OVERVIEW_CACHE_TTL_SECONDS = 60; +export const LP_OVERVIEW_CACHE_KEY = 'admin:lp-overview'; + +export interface LpOverview { + /** Sum of all LP allocations recorded across every key, in XLM. */ + totalProtocolLpXlm: string; + /** Number of distinct keys with at least one recorded LP allocation. */ + keyCount: number; + /** Total number of LP allocation events recorded. */ + allocationCount: number; +} + +export async function invalidateLpOverviewCache(): Promise { + await cacheInvalidate(LP_OVERVIEW_CACHE_KEY); +} + +/** + * Aggregates protocol-owned liquidity across all keys: total LP XLM + * contributed, the number of keys holding LP, and the number of + * allocation events that make up the total. + */ +export async function getProtocolLpOverview(): Promise { + const cached = await cacheGetJson(LP_OVERVIEW_CACHE_KEY); + if (cached) { + return cached; + } + + const [aggregate, distinctKeys] = await Promise.all([ + prisma.lpAllocation.aggregate({ + _sum: { amountXlm: true }, + _count: { _all: true }, + }), + prisma.lpAllocation.findMany({ + distinct: ['creatorId'], + select: { creatorId: true }, + }), + ]); + + const overview: LpOverview = { + totalProtocolLpXlm: aggregate._sum.amountXlm?.toString() ?? '0', + keyCount: distinctKeys.length, + allocationCount: aggregate._count._all, + }; + + await cacheSetJson( + LP_OVERVIEW_CACHE_KEY, + overview, + LP_OVERVIEW_CACHE_TTL_SECONDS + ); + return overview; +} diff --git a/src/modules/indexer/lp-allocation-indexer.service.ts b/src/modules/indexer/lp-allocation-indexer.service.ts new file mode 100644 index 00000000..b536fc40 --- /dev/null +++ b/src/modules/indexer/lp-allocation-indexer.service.ts @@ -0,0 +1,96 @@ +// src/modules/indexer/lp-allocation-indexer.service.ts +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { + processIndexerChainEvents, + IndexerChainEvent, +} from '../../utils/indexer-event-processor.utils'; +import { invalidateKeyLpStatsCache } from '../keys/key-lp.service'; +import { invalidateLpOverviewCache } from '../admin/lp-overview.service'; + +/** + * Chain event for the LPAllocationSent contract event (#943), emitted when a + * bonding-curve buy sends its LP-allocated share of proceeds into the pool. + */ +export interface LPAllocationSentEvent extends IndexerChainEvent { + eventType: 'LP_ALLOCATION_SENT'; + creatorId: string; + amountXlm: string; + allocatedAt: string; // ISO timestamp +} + +/** + * Processes a batch of LP_ALLOCATION_SENT events, storing one LpAllocation + * row per event. Already-recorded events (same txHash + eventIndex) are + * skipped so replays are idempotent. Invalidates the affected key's LP + * stats cache and the admin protocol-wide LP overview cache on every new + * allocation recorded. + */ +export async function processLpAllocationEvents( + events: IndexerChainEvent[] +): Promise { + await processIndexerChainEvents(events, async event => { + if (event.eventType !== 'LP_ALLOCATION_SENT') { + return; + } + + const typedEvent = event as LPAllocationSentEvent; + const requiredFields = [ + 'creatorId', + 'amountXlm', + 'allocatedAt', + 'ledger', + 'txHash', + ]; + for (const field of requiredFields) { + const value = typedEvent[field as keyof LPAllocationSentEvent]; + if (value === undefined || value === null || value === '') { + logger.warn( + { + eventId: `${event.txHash}:${event.eventIndex}`, + missingField: field, + }, + 'Skipping LP allocation event due to missing required field' + ); + return; + } + } + + const existing = await prisma.lpAllocation.findUnique({ + where: { + txHash_eventIndex: { + txHash: String(typedEvent.txHash), + eventIndex: typedEvent.eventIndex, + }, + }, + select: { id: true }, + }); + if (existing) { + return; + } + + await prisma.lpAllocation.create({ + data: { + creatorId: typedEvent.creatorId, + amountXlm: typedEvent.amountXlm, + ledger: Number(typedEvent.ledger), + txHash: String(typedEvent.txHash), + eventIndex: typedEvent.eventIndex, + allocatedAt: new Date(typedEvent.allocatedAt), + }, + }); + + await invalidateKeyLpStatsCache(typedEvent.creatorId); + await invalidateLpOverviewCache(); + + logger.info( + { + creatorId: typedEvent.creatorId, + amountXlm: typedEvent.amountXlm, + ledger: Number(typedEvent.ledger), + txHash: String(typedEvent.txHash), + }, + 'LP allocation recorded' + ); + }); +} diff --git a/src/modules/indexer/lp-allocation.integration.test.ts b/src/modules/indexer/lp-allocation.integration.test.ts new file mode 100644 index 00000000..b80670b7 --- /dev/null +++ b/src/modules/indexer/lp-allocation.integration.test.ts @@ -0,0 +1,271 @@ +import request from 'supertest'; +import { createServer } from '../../utils/server.utils'; +import { prisma } from '../../utils/prisma.utils'; +import jwt from 'jsonwebtoken'; +import { envConfig } from '../../config'; +import { processLpAllocationEvents } from './lp-allocation-indexer.service'; +import { IndexerChainEvent } from '../../utils/indexer-event-processor.utils'; +import { cacheGetJson } from '../../utils/redis.utils'; +import { getKeyLpStatsCacheKey } from '../keys/key-lp.service'; +import { LP_OVERVIEW_CACHE_KEY } from '../admin/lp-overview.service'; + +function adminToken(): string { + return jwt.sign( + { sub: 'admin-test', role: 'admin' }, + envConfig.JWT_SECRET, + { expiresIn: '1h' } + ); +} + +function lpEvent( + overrides: Partial & { creatorId: string } +): IndexerChainEvent { + return { + eventType: 'LP_ALLOCATION_SENT', + amountXlm: '100', + allocatedAt: new Date().toISOString(), + ledger: 1000, + txHash: `tx-${Math.random().toString(36).slice(2)}`, + eventIndex: 0, + ...overrides, + } as IndexerChainEvent; +} + +describe('LP allocation tracking and TVL reporting (#943)', () => { + let app: any; + let creatorAId: string; + let creatorBId: string; + + beforeAll(async () => { + app = await createServer(); + }); + + beforeEach(async () => { + await prisma.lpAllocation.deleteMany({}); + await prisma.creatorProfile.deleteMany({}); + await prisma.user.deleteMany({}); + + const userA = await prisma.user.create({ + data: { + email: `lp-test-a-${Date.now()}@example.com`, + passwordHash: 'hash123', + firstName: 'A', + lastName: 'Creator', + }, + }); + const creatorA = await prisma.creatorProfile.create({ + data: { + userId: userA.id, + handle: `lp-creator-a-${Date.now()}`, + displayName: 'Creator A', + }, + }); + creatorAId = creatorA.id; + + const userB = await prisma.user.create({ + data: { + email: `lp-test-b-${Date.now()}@example.com`, + passwordHash: 'hash123', + firstName: 'B', + lastName: 'Creator', + }, + }); + const creatorB = await prisma.creatorProfile.create({ + data: { + userId: userB.id, + handle: `lp-creator-b-${Date.now()}`, + displayName: 'Creator B', + }, + }); + creatorBId = creatorB.id; + }); + + afterAll(async () => { + await prisma.lpAllocation.deleteMany({}); + }); + + it('AC1: records LP allocations correctly from contract events', async () => { + const event = lpEvent({ creatorId: creatorAId, amountXlm: '50' }); + await processLpAllocationEvents([event]); + + const rows = await prisma.lpAllocation.findMany({ + where: { creatorId: creatorAId }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].amountXlm.toString()).toBe('50.0000000'); + expect(rows[0].txHash).toBe(event.txHash); + }); + + it('AC1: is idempotent on replay of the same txHash + eventIndex', async () => { + const event = lpEvent({ + creatorId: creatorAId, + amountXlm: '50', + txHash: 'tx-fixed-1', + eventIndex: 2, + }); + await processLpAllocationEvents([event]); + await processLpAllocationEvents([event]); + + const rows = await prisma.lpAllocation.findMany({ + where: { creatorId: creatorAId }, + }); + expect(rows).toHaveLength(1); + }); + + it('AC1: skips events missing required fields', async () => { + const badEvent = { + eventType: 'LP_ALLOCATION_SENT', + creatorId: creatorAId, + // amountXlm missing + allocatedAt: new Date().toISOString(), + ledger: 1000, + txHash: 'tx-missing-field', + eventIndex: 0, + } as unknown as IndexerChainEvent; + + await processLpAllocationEvents([badEvent]); + + const rows = await prisma.lpAllocation.findMany({ + where: { creatorId: creatorAId }, + }); + expect(rows).toHaveLength(0); + }); + + it('AC2: GET /keys/:id/lp-stats returns correct cumulative amounts', async () => { + await processLpAllocationEvents([ + lpEvent({ creatorId: creatorAId, amountXlm: '100', txHash: 'tx-a1' }), + lpEvent({ creatorId: creatorAId, amountXlm: '25.5', txHash: 'tx-a2' }), + ]); + + const res = await request(app).get(`/api/v1/keys/${creatorAId}/lp-stats`); + + expect(res.status).toBe(200); + expect(res.body.data.keyId).toBe(creatorAId); + expect(res.body.data.totalLpContributedXlm).toBe('125.5000000'); + expect(res.body.data.currentLpBalanceXlm).toBe('125.5000000'); + expect(res.body.data.allocationCount).toBe(2); + }); + + it('AC2: 404 for an unknown key', async () => { + const res = await request(app).get('/api/v1/keys/does-not-exist/lp-stats'); + expect(res.status).toBe(404); + }); + + it('AC3: GET /admin/lp-overview aggregates LP correctly across all keys', async () => { + await processLpAllocationEvents([ + lpEvent({ creatorId: creatorAId, amountXlm: '100', txHash: 'tx-ov1' }), + lpEvent({ creatorId: creatorBId, amountXlm: '200', txHash: 'tx-ov2' }), + ]); + + const res = await request(app) + .get('/api/v1/admin/lp-overview') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(res.status).toBe(200); + expect(res.body.data.totalProtocolLpXlm).toBe('300.0000000'); + expect(res.body.data.keyCount).toBe(2); + expect(res.body.data.allocationCount).toBe(2); + }); + + it('AC3: 403 for admin overview without an admin token', async () => { + const res = await request(app).get('/api/v1/admin/lp-overview'); + expect([401, 403]).toContain(res.status); + }); + + it('AC4: cache is invalidated on a new LPAllocationSent event', async () => { + await processLpAllocationEvents([ + lpEvent({ creatorId: creatorAId, amountXlm: '10', txHash: 'tx-c1' }), + ]); + + const first = await request(app).get( + `/api/v1/keys/${creatorAId}/lp-stats` + ); + expect(first.body.data.totalLpContributedXlm).toBe('10.0000000'); + + // Stats are now cached for the key. + const cachedBefore = await cacheGetJson(getKeyLpStatsCacheKey(creatorAId)); + expect(cachedBefore).not.toBeNull(); + + await processLpAllocationEvents([ + lpEvent({ creatorId: creatorAId, amountXlm: '15', txHash: 'tx-c2' }), + ]); + + // The new event must have invalidated the stale cache entry. + const cachedAfter = await cacheGetJson(getKeyLpStatsCacheKey(creatorAId)); + expect(cachedAfter).toBeNull(); + + const second = await request(app).get( + `/api/v1/keys/${creatorAId}/lp-stats` + ); + expect(second.body.data.totalLpContributedXlm).toBe('25.0000000'); + }); + + it('AC4: admin overview cache is invalidated on a new event', async () => { + await processLpAllocationEvents([ + lpEvent({ creatorId: creatorAId, amountXlm: '10', txHash: 'tx-d1' }), + ]); + await request(app) + .get('/api/v1/admin/lp-overview') + .set('Authorization', `Bearer ${adminToken()}`); + + const cachedBefore = await cacheGetJson(LP_OVERVIEW_CACHE_KEY); + expect(cachedBefore).not.toBeNull(); + + await processLpAllocationEvents([ + lpEvent({ creatorId: creatorBId, amountXlm: '5', txHash: 'tx-d2' }), + ]); + + const cachedAfter = await cacheGetJson(LP_OVERVIEW_CACHE_KEY); + expect(cachedAfter).toBeNull(); + }); + + it('AC5: GET /keys/:id/lp-history paginates correctly with a cursor', async () => { + await processLpAllocationEvents([ + lpEvent({ + creatorId: creatorAId, + amountXlm: '1', + txHash: 'tx-p1', + allocatedAt: new Date('2024-01-01T00:00:00Z').toISOString(), + }), + lpEvent({ + creatorId: creatorAId, + amountXlm: '2', + txHash: 'tx-p2', + allocatedAt: new Date('2024-01-02T00:00:00Z').toISOString(), + }), + lpEvent({ + creatorId: creatorAId, + amountXlm: '3', + txHash: 'tx-p3', + allocatedAt: new Date('2024-01-03T00:00:00Z').toISOString(), + }), + ]); + + const firstPage = await request(app).get( + `/api/v1/keys/${creatorAId}/lp-history?limit=2` + ); + expect(firstPage.status).toBe(200); + expect(firstPage.body.data.entries).toHaveLength(2); + // Newest first. + expect(firstPage.body.data.entries[0].txHash).toBe('tx-p3'); + expect(firstPage.body.data.entries[1].txHash).toBe('tx-p2'); + expect(firstPage.body.data.pagination.hasMore).toBe(true); + const cursor = firstPage.body.data.pagination.nextCursor; + expect(typeof cursor).toBe('string'); + + const secondPage = await request(app).get( + `/api/v1/keys/${creatorAId}/lp-history?limit=2&cursor=${cursor}` + ); + expect(secondPage.status).toBe(200); + expect(secondPage.body.data.entries).toHaveLength(1); + expect(secondPage.body.data.entries[0].txHash).toBe('tx-p1'); + expect(secondPage.body.data.pagination.hasMore).toBe(false); + }); + + it('AC5: 404 for lp-history on an unknown key', async () => { + const res = await request(app).get( + '/api/v1/keys/does-not-exist/lp-history' + ); + expect(res.status).toBe(404); + }); +}); diff --git a/src/modules/keys/key-lp.service.ts b/src/modules/keys/key-lp.service.ts new file mode 100644 index 00000000..6e42707c --- /dev/null +++ b/src/modules/keys/key-lp.service.ts @@ -0,0 +1,134 @@ +// src/modules/keys/key-lp.service.ts +// Liquidity-pool allocation tracking per key: cumulative LP contributed from +// bonding-curve buys (LPAllocationSent contract events) and the protocol's +// current LP balance for that key (#943). Stats are cached 60s; the LP +// allocation indexer invalidates the cache on every new event. +import { prisma } from '../../utils/prisma.utils'; +import { + cacheGetJson, + cacheInvalidate, + cacheSetJson, +} from '../../utils/redis.utils'; +import { KeyNotFoundError } from './key-fees.service'; + +export const KEY_LP_STATS_CACHE_TTL_SECONDS = 60; + +export interface KeyLpStats { + keyId: string; + /** Cumulative LP sent to the pool for this key, in XLM. Monotonic. */ + totalLpContributedXlm: string; + /** + * Current LP balance held for this key, in XLM. No "LP removed" contract + * event exists in scope yet, so this currently equals the cumulative + * amount contributed. + */ + currentLpBalanceXlm: string; + allocationCount: number; +} + +export interface LpAllocationEntry { + id: string; + amountXlm: string; + txHash: string; + ledger: number; + allocatedAt: string; +} + +export interface LpAllocationPage { + entries: LpAllocationEntry[]; + pagination: { + limit: number; + hasMore: boolean; + nextCursor?: string; + }; +} + +export function getKeyLpStatsCacheKey(keyId: string): string { + return `key:lp-stats:${keyId}`; +} + +export async function invalidateKeyLpStatsCache(keyId: string): Promise { + await cacheInvalidate(getKeyLpStatsCacheKey(keyId)); +} + +async function requireKey(keyId: string): Promise { + const creator = await prisma.creatorProfile.findUnique({ + where: { id: keyId }, + select: { id: true }, + }); + if (!creator) { + throw new KeyNotFoundError(keyId); + } +} + +/** + * Total LP contributed and current LP balance for a single key, cached + * 60s. Throws KeyNotFoundError for unknown key IDs. + */ +export async function getKeyLpStats(keyId: string): Promise { + const cacheKey = getKeyLpStatsCacheKey(keyId); + const cached = await cacheGetJson(cacheKey); + if (cached) { + return cached; + } + + await requireKey(keyId); + + const aggregate = await prisma.lpAllocation.aggregate({ + where: { creatorId: keyId }, + _sum: { amountXlm: true }, + _count: { _all: true }, + }); + + const totalContributed = aggregate._sum.amountXlm?.toString() ?? '0'; + const stats: KeyLpStats = { + keyId, + totalLpContributedXlm: totalContributed, + currentLpBalanceXlm: totalContributed, + allocationCount: aggregate._count._all, + }; + + await cacheSetJson(cacheKey, stats, KEY_LP_STATS_CACHE_TTL_SECONDS); + return stats; +} + +/** + * Paginated LP contribution history for a key, newest first, using + * cursor-based pagination (cursor is the last row id of the previous page). + */ +export async function getKeyLpHistory(input: { + keyId: string; + limit: number; + cursor?: string; +}): Promise { + const { keyId, limit, cursor } = input; + + await requireKey(keyId); + + const allocations = await prisma.lpAllocation.findMany({ + where: { creatorId: keyId }, + orderBy: [{ allocatedAt: 'desc' }, { id: 'desc' }], + take: limit + 1, + skip: cursor ? 1 : 0, + cursor: cursor ? { id: cursor } : undefined, + }); + + const hasMore = allocations.length > limit; + const page = allocations.slice(0, limit); + + return { + entries: page.map(allocation => ({ + id: allocation.id, + amountXlm: allocation.amountXlm.toString(), + txHash: allocation.txHash, + ledger: allocation.ledger, + allocatedAt: allocation.allocatedAt.toISOString(), + })), + pagination: { + limit, + hasMore, + nextCursor: + hasMore && page.length > 0 ? page[page.length - 1].id : undefined, + }, + }; +} diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index b31f3a12..4ad074c5 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -16,6 +16,7 @@ import { PRICE_HISTORY_INTERVALS, } from './key-price-history.service'; import { getKeyFees, KeyNotFoundError } from './key-fees.service'; +import { getKeyLpStats, getKeyLpHistory } from './key-lp.service'; import { getOraclePrice, KeyNotFoundError as OracleKeyNotFoundError, @@ -95,6 +96,11 @@ const walletQuerySchema = z.object({ wallet: StellarAddressSchema, }); +const lpHistoryQuerySchema = z.object({ + limit: z.coerce.number().int().positive().max(100).optional().default(20), + cursor: z.string().min(1).optional(), +}); + const router = Router(); /** @@ -286,6 +292,54 @@ router.get('/:keyId/fees', async (req, res, next) => { } }); +/** + * GET /api/v1/keys/:keyId/lp-stats + * Total LP contributed and current LP balance for a key, sourced from + * LPAllocationSent contract events. Cached 60s (#943). + */ +router.get('/:keyId/lp-stats', async (req, res, next) => { + try { + sendSuccess(res, await getKeyLpStats(String(req.params.keyId))); + } catch (error) { + if (error instanceof KeyNotFoundError) { + sendNotFound(res, 'Key'); + return; + } + next(error); + } +}); + +/** + * GET /api/v1/keys/:keyId/lp-history?limit=&cursor= + * Paginated LP contribution history for a key, newest first (#943). + */ +router.get('/:keyId/lp-history', async (req, res, next) => { + const parsed = lpHistoryQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid pagination query', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + try { + sendSuccess( + res, + await getKeyLpHistory({ + keyId: String(req.params.keyId), + ...parsed.data, + }) + ); + } catch (error) { + if (error instanceof KeyNotFoundError) { + sendNotFound(res, 'Key'); + return; + } + next(error); + } +}); + /** * GET /api/v1/keys/:keyId/proposals?status=active|closed * List governance proposals for a creator key. From b214f4ea86c4e4a59c6f43b6160d2c3caf986728 Mon Sep 17 00:00:00 2001 From: King David Date: Sat, 26 Sep 2026 00:43:25 -0700 Subject: [PATCH 2/3] Refactor lp-overview route to use destructured req --- src/modules/admin/admin.routes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/admin/admin.routes.ts b/src/modules/admin/admin.routes.ts index 051c5e01..71c84f98 100644 --- a/src/modules/admin/admin.routes.ts +++ b/src/modules/admin/admin.routes.ts @@ -148,7 +148,7 @@ adminRouter.get('/analytics', adminGuard, async (req: AdminRequest, res, next) = * number of keys holding LP, and the number of allocation events. Sourced * from LPAllocationSent contract events. Cached 60s (#943). */ -adminRouter.get('/lp-overview', adminGuard, async (req: AdminRequest, res, next) => { +adminRouter.get('/lp-overview', adminGuard, async (_req: AdminRequest, res, next) => { try { sendSuccess(res, await getProtocolLpOverview()); } catch (error) { @@ -1083,4 +1083,4 @@ adminRouter.post( } ); -export default adminRouter; \ No newline at end of file +export default adminRouter; From f6ab901c6e865d68f4d5675ae801e5ef4c5c8db8 Mon Sep 17 00:00:00 2001 From: King David Date: Sat, 26 Sep 2026 05:43:55 -0700 Subject: [PATCH 3/3] Update lpHistoryQuerySchema to include 'cursor' Add a new optional field 'cursor' to lpHistoryQuerySchema. --- src/modules/keys/keys.routes.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index b8d0255e..d01dafc8 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -124,6 +124,8 @@ const walletQuerySchema = z.object({ const lpHistoryQuerySchema = z.object({ limit: z.coerce.number().int().positive().max(100).optional().default(20), cursor: z.string().min(1).optional(), +}); + const priceImpactQuerySchema = z.object({ quantity: z.string().transform(v => { const num = parseInt(v, 10);