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
24 changes: 24 additions & 0 deletions prisma/schema/lp-allocation.prisma
Original file line number Diff line number Diff line change
@@ -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])
}
19 changes: 18 additions & 1 deletion src/modules/admin/admin.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
analyticsWindowQuerySchema,
getPlatformAnalytics,
} from '../keys/key-analytics.service';
import { getProtocolLpOverview } from './lp-overview.service';
import { flashLoanViolationsQuerySchema } from './flash-loan-violations.schemas';
import { getFlashLoanViolations } from './flash-loan-violations.service';
import {
Expand Down Expand Up @@ -177,6 +178,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
*
Expand Down Expand Up @@ -1103,4 +1120,4 @@ adminRouter.post(
}
);

export default adminRouter;
export default adminRouter;
62 changes: 62 additions & 0 deletions src/modules/admin/lp-overview.service.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<LpOverview> {
const cached = await cacheGetJson<LpOverview>(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;
}
96 changes: 96 additions & 0 deletions src/modules/indexer/lp-allocation-indexer.service.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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'
);
});
}
Loading
Loading