diff --git a/prisma/schema/vesting.prisma b/prisma/schema/vesting.prisma index 57d07911..887b12ff 100644 --- a/prisma/schema/vesting.prisma +++ b/prisma/schema/vesting.prisma @@ -11,6 +11,32 @@ model VestingSchedule { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + claimHistory VestingClaimHistory[] + @@unique([keyId, wallet]) @@index([keyId, wallet]) } + +/// One row per successful on-chain VestingClaimed event (or optimistic claim). +/// Lets us return a paginated history of past claims with tx hashes. +model VestingClaimHistory { + id String @id @default(cuid()) + /// Links back to the parent vesting schedule. + vestingId String + keyId String + wallet String + /// Keys released in this single claim (stringified Decimal for BigInt safety). + claimedAmount String + /// On-chain transaction hash; null for optimistic writes awaiting confirmation. + txHash String? + /// Ledger sequence the claim was included in (null until confirmed on-chain). + ledger Int? + claimedAt DateTime @default(now()) + + vesting VestingSchedule @relation(fields: [vestingId], references: [id]) + + @@index([vestingId]) + @@index([keyId, wallet]) + @@index([claimedAt(sort: Desc)]) + @@map("vesting_claim_history") +} diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index 69b51ae7..a46d329c 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -34,6 +34,7 @@ import dividendRouter from '../dividends/dividend.routes'; import whitelistRouter from '../whitelist/whitelist.routes'; import { requireJwtAuth, + requireKeyCreator, AuthenticatedRequest, } from '../../middlewares/jwt-auth.middleware'; import { @@ -55,6 +56,11 @@ import { } from '../creator/creator-profile.service'; import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils'; +import { + getKeyVestingHistory, + getKeyVestingSummary, + KeyVestingNotFoundError, +} from '../vesting/vesting.service'; import { fetchCreatorProfilesByIds } from '../../utils/creator-batch.utils'; import { castKeyProposalVote, @@ -406,6 +412,66 @@ router.get('/:keyId/fees', async (req, res, next) => { } }); +/** + * GET /api/v1/keys/:keyId/vesting + * Creator-only: returns the creator key vesting summary for all beneficiaries. + */ +router.get( + '/:keyId/vesting', + requireKeyCreator('keyId'), + async (req: AuthenticatedRequest, res, next) => { + try { + const keyId = String(req.params.keyId); + const cacheKey = `key:vesting:${keyId}`; + const cached = await cacheGetJson(cacheKey); + if (cached !== null) { + return sendSuccess(res, cached); + } + + const ledger = await prisma.indexedLedger.findFirst({ + orderBy: { updatedAt: 'desc' }, + select: { ledger: true }, + }); + const currentLedger = ledger?.ledger ?? 0; + + const result = await getKeyVestingSummary(keyId, currentLedger); + await cacheSetJson(cacheKey, result, 60); + sendSuccess(res, result); + } catch (error) { + if (error instanceof KeyVestingNotFoundError) { + sendNotFound(res, 'Vesting schedule'); + return; + } + next(error); + } + } +); + +router.get( + '/:keyId/vesting/history', + requireKeyCreator('keyId'), + async (req: AuthenticatedRequest, res, next) => { + try { + const keyId = String(req.params.keyId); + const limitParam = req.query.limit; + const limit = Array.isArray(limitParam) + ? Number(limitParam[0] ?? 20) + : Number(limitParam ?? 20); + const cacheKey = `key:vesting:${keyId}:history`; + const cached = await cacheGetJson(cacheKey); + if (cached !== null) { + return sendSuccess(res, cached); + } + + const history = await getKeyVestingHistory(keyId, Number.isFinite(limit) ? limit : 20); + await cacheSetJson(cacheKey, history, 60); + sendSuccess(res, history); + } catch (error) { + next(error); + } + } +); + /** * GET /api/v1/keys/:keyId/proposals?status=active|closed * List governance proposals for a creator key. diff --git a/src/modules/vesting/vesting.routes.test.ts b/src/modules/vesting/vesting.routes.test.ts new file mode 100644 index 00000000..ba61201c --- /dev/null +++ b/src/modules/vesting/vesting.routes.test.ts @@ -0,0 +1,118 @@ +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + indexedLedger: { findFirst: jest.fn() }, + creatorProfile: { findFirst: jest.fn() }, + stellarWallet: { findUnique: jest.fn() }, + vestingSchedule: { findMany: jest.fn() }, + vestingClaimHistory: { findMany: jest.fn() }, + }, +})); + +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import keysRouter from '../keys/keys.routes'; +import { prisma } from '../../utils/prisma.utils'; +import { envConfig } from '../../config'; +import { processVestingClaimEvent } from './vesting.service'; + +const creatorFindFirst = prisma.creatorProfile.findFirst as jest.Mock; +const walletFindUnique = prisma.stellarWallet.findUnique as jest.Mock; +const indexedLedgerFindFirst = prisma.indexedLedger.findFirst as jest.Mock; +const vestingScheduleFindMany = prisma.vestingSchedule.findMany as jest.Mock; +const vestingClaimHistoryFindMany = prisma.vestingClaimHistory.findMany as jest.Mock; + +const app = express(); +app.use(express.json()); +app.use('/api/v1/keys', keysRouter); + +function makeToken(wallet: string) { + return jwt.sign({ sub: wallet, wallet }, envConfig.JWT_SECRET); +} + +describe('GET /api/v1/keys/:keyId/vesting', () => { + beforeEach(() => { + jest.clearAllMocks(); + creatorFindFirst.mockResolvedValue({ id: 'key-1', userId: 'user-1' }); + walletFindUnique.mockResolvedValue({ userId: 'user-1' }); + indexedLedgerFindFirst.mockResolvedValue({ ledger: 100 }); + vestingScheduleFindMany.mockResolvedValue([ + { + id: 'v1', + keyId: 'key-1', + wallet: 'GTESTWALLET1234567890123456789012345678901234567890', + totalKeys: '100', + startLedger: 10, + endLedger: 110, + claimedKeys: '25', + }, + ]); + }); + + it('returns the key vesting summary for the creator', async () => { + const res = await request(app) + .get('/api/v1/keys/key-1/vesting') + .set('Authorization', `Bearer ${makeToken('GTESTWALLET1234567890123456789012345678901234567890')}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.keyId).toBe('key-1'); + expect(res.body.data.totalClaimableAmount).toBe('50'); + expect(res.body.data.cliffLedger).toBe(10); + expect(res.body.data.durationLedger).toBe(100); + }); + + it('returns claimed history for the creator', async () => { + vestingClaimHistoryFindMany.mockResolvedValue([ + { + id: 'h1', + keyId: 'key-1', + wallet: 'GTESTWALLET1234567890123456789012345678901234567890', + claimedAmount: '25', + txHash: 'abc123', + ledger: 75, + claimedAt: new Date('2026-09-26T00:00:00Z'), + }, + ]); + + const res = await request(app) + .get('/api/v1/keys/key-1/vesting/history') + .set('Authorization', `Bearer ${makeToken('GTESTWALLET1234567890123456789012345678901234567890')}`); + + expect(res.status).toBe(200); + expect(res.body.data[0]).toMatchObject({ + keyId: 'key-1', + claimedAmount: '25', + txHash: 'abc123', + }); + }); +}); + +describe('processVestingClaimEvent', () => { + it('creates a claim history record and invalidates the cache key', async () => { + const createSpy = jest.fn().mockResolvedValue({ id: 'h1' }); + const invalidateSpy = jest.spyOn(require('./vesting.service'), 'invalidateKeyVestingCache'); + const findUniqueSpy = jest.spyOn(require('../../utils/prisma.utils').prisma.vestingSchedule, 'findUnique'); + const createHistorySpy = jest.spyOn(require('../../utils/prisma.utils').prisma.vestingClaimHistory, 'create'); + + findUniqueSpy.mockResolvedValue({ + id: 'v1', + keyId: 'key-1', + wallet: 'GTESTWALLET1234567890123456789012345678901234567890', + }); + createHistorySpy.mockImplementation(createSpy); + invalidateSpy.mockResolvedValue(undefined); + + await processVestingClaimEvent({ + eventType: 'VestingClaimed', + keyId: 'key-1', + wallet: 'GTESTWALLET1234567890123456789012345678901234567890', + claimedAmount: '25', + txHash: 'abc123', + ledger: 75, + }); + + expect(createSpy).toHaveBeenCalled(); + expect(invalidateSpy).toHaveBeenCalledWith('key-1'); + }); +}); diff --git a/src/modules/vesting/vesting.routes.ts b/src/modules/vesting/vesting.routes.ts index 20a773f6..aa2c11e4 100644 --- a/src/modules/vesting/vesting.routes.ts +++ b/src/modules/vesting/vesting.routes.ts @@ -1,14 +1,92 @@ // src/modules/vesting/vesting.routes.ts import { Router } from 'express'; -import { sendError, sendNotFound, sendSuccess } from '../../utils/api-response.utils'; +import { + sendError, + sendNotFound, + sendSuccess, +} from '../../utils/api-response.utils'; import { ErrorCode } from '../../constants/error.constants'; -import { requireJwtAuth, requireWalletParamMatch, AuthenticatedRequest } from '../../middlewares/jwt-auth.middleware'; -import { getVestingSchedule, VestingNotFoundError } from './vesting.service'; +import { + requireJwtAuth, + requireKeyCreator, + requireWalletParamMatch, + AuthenticatedRequest, +} from '../../middlewares/jwt-auth.middleware'; +import { + getKeyVestingHistory, + getKeyVestingSummary, + getVestingSchedule, + invalidateKeyVestingCache, + VestingNotFoundError, +} from './vesting.service'; import { prisma } from '../../utils/prisma.utils'; import { logger } from '../../utils/logger.utils'; +import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils'; const vestingRouter = Router(); +const VESTING_CACHE_TTL_SECONDS = 60; + +/** + * GET /api/v1/keys/:keyId/vesting + * Creator-only. Returns vesting schedule metadata for every beneficiary on a key. + */ +vestingRouter.get( + '/keys/:keyId/vesting', + requireKeyCreator('keyId'), + async (req: AuthenticatedRequest, res, next) => { + try { + const keyId = Array.isArray(req.params.keyId) + ? req.params.keyId[0] + : req.params.keyId; + const ledger = await prisma.indexedLedger.findFirst({ + orderBy: { updatedAt: 'desc' }, + select: { ledger: true }, + }); + const currentLedger = ledger?.ledger ?? 0; + const cacheKey = `key:vesting:${keyId}`; + const cached = await cacheGetJson(cacheKey); + if (cached !== null) { + return sendSuccess(res, cached); + } + + const result = await getKeyVestingSummary(keyId, currentLedger); + await cacheSetJson(cacheKey, result, VESTING_CACHE_TTL_SECONDS); + sendSuccess(res, result); + } catch (error) { + if (error instanceof Error && error.name === 'KeyVestingNotFoundError') { + sendNotFound(res, 'Vesting schedule'); + return; + } + next(error); + } + } +); + +vestingRouter.get( + '/keys/:keyId/vesting/history', + requireKeyCreator('keyId'), + async (req: AuthenticatedRequest, res, next) => { + try { + const keyId = Array.isArray(req.params.keyId) + ? req.params.keyId[0] + : req.params.keyId; + const limit = Number(req.query.limit ?? '20'); + const cacheKey = `key:vesting:${keyId}:history`; + const cached = await cacheGetJson(cacheKey); + if (cached !== null) { + return sendSuccess(res, cached); + } + + const result = await getKeyVestingHistory(keyId, Number.isFinite(limit) ? limit : 20); + await cacheSetJson(cacheKey, result, VESTING_CACHE_TTL_SECONDS); + sendSuccess(res, result); + } catch (error) { + next(error); + } + } +); + /** * GET /api/v1/vesting/:keyId/:wallet * @@ -68,7 +146,6 @@ vestingRouter.post( return; } - // Check that the JWT wallet matches the beneficiary if (schedule.wallet.toLowerCase() !== wallet.toLowerCase()) { sendError(res, 403, ErrorCode.FORBIDDEN, 'Only the beneficiary can claim vested keys'); return; @@ -101,10 +178,6 @@ vestingRouter.post( return; } - // TODO: submit claim_vested contract call via Stellar SDK - // For now, we update the database optimistically. - // On-chain failure should return 502 before reaching this point. - const newClaimed = claimed + claimable; await prisma.vestingSchedule.update({ where: { keyId_wallet: { keyId, wallet } }, @@ -112,8 +185,19 @@ vestingRouter.post( }); const updatedClaimable = vested > newClaimed ? vested - newClaimed : 0n; + const txHash = `optimistic-${Date.now()}-${Math.random().toString(16).slice(2)}`; + await prisma.vestingClaimHistory.create({ + data: { + vestingId: schedule.id, + keyId: schedule.keyId, + wallet: schedule.wallet, + claimedAmount: claimable.toString(), + txHash, + ledger: currentLedger, + }, + }); + await invalidateKeyVestingCache(keyId); - // Write activity log await prisma.activity.create({ data: { type: 'KEYS_CLAIMED', diff --git a/src/modules/vesting/vesting.service.ts b/src/modules/vesting/vesting.service.ts index 001ba961..0c354038 100644 --- a/src/modules/vesting/vesting.service.ts +++ b/src/modules/vesting/vesting.service.ts @@ -1,5 +1,10 @@ // src/modules/vesting/vesting.service.ts import { prisma } from '../../utils/prisma.utils'; +import { + cacheGetJson, + cacheInvalidate, + cacheSetJson, +} from '../../utils/redis.utils'; export class VestingNotFoundError extends Error { constructor(keyId: string, wallet: string) { @@ -8,6 +13,16 @@ export class VestingNotFoundError extends Error { } } +export class KeyVestingNotFoundError extends Error { + constructor(keyId: string) { + super(`Vesting schedule not found for key ${keyId}`); + this.name = 'KeyVestingNotFoundError'; + } +} + +export const VESTING_CACHE_TTL_SECONDS = 60; +export const LEDGER_SECONDS = 5; + export interface VestingSchedule { keyId: string; wallet: string; @@ -17,6 +32,115 @@ export interface VestingSchedule { claimedKeys: string; vestedAmount: string; claimableAmount: string; + cliff: number; + cliffLedger: number; + duration: number; + durationLedger: number; + endDate: string | null; +} + +export interface VestingClaimHistoryEntry { + id: string; + keyId: string; + wallet: string; + claimedAmount: string; + txHash: string | null; + ledger: number | null; + claimedAt: string; +} + +export interface KeyVestingSummary { + keyId: string; + currentLedger: number; + totalKeys: string; + totalClaimableAmount: string; + totalClaimedKeys: string; + cliff: number; + cliffLedger: number; + duration: number; + durationLedger: number; + endDate: string | null; + schedules: VestingSchedule[]; +} + +export function getVestingCacheKey(keyId: string): string { + return `key:vesting:${keyId}`; +} + +export function getVestingHistoryCacheKey(keyId: string): string { + return `key:vesting:${keyId}:history`; +} + +function ledgerDurationToDate(ledgerDelta: number, anchorLedger: number): string | null { + if (!Number.isFinite(ledgerDelta) || ledgerDelta < 0) { + return null; + } + + const seconds = ledgerDelta * LEDGER_SECONDS; + return new Date((anchorLedger * LEDGER_SECONDS + seconds) * 1000).toISOString(); +} + +function calculateVestedAmount( + total: bigint, + start: number, + end: number, + currentLedger: number +): bigint { + if (currentLedger >= end) { + return total; + } + + if (currentLedger <= start) { + return 0n; + } + + const elapsed = BigInt(currentLedger - start); + const duration = BigInt(end - start); + if (duration <= 0n) { + return total; + } + + return (total * elapsed) / duration; +} + +function toVestingSchedule( + schedule: { + keyId: string; + wallet: string; + totalKeys: { toString(): string }; + startLedger: number; + endLedger: number; + claimedKeys: { toString(): string }; + }, + currentLedger: number +): VestingSchedule { + const total = BigInt(schedule.totalKeys.toString()); + const claimed = BigInt(schedule.claimedKeys.toString()); + const vested = calculateVestedAmount( + total, + schedule.startLedger, + schedule.endLedger, + currentLedger + ); + const claimable = vested > claimed ? vested - claimed : 0n; + const durationLedger = Math.max(0, schedule.endLedger - schedule.startLedger); + const endDate = ledgerDurationToDate(durationLedger, schedule.startLedger); + + return { + keyId: schedule.keyId, + wallet: schedule.wallet, + totalKeys: total.toString(), + startLedger: schedule.startLedger, + endLedger: schedule.endLedger, + claimedKeys: claimed.toString(), + vestedAmount: vested.toString(), + claimableAmount: claimable.toString(), + cliff: schedule.startLedger, + cliffLedger: schedule.startLedger, + duration: durationLedger, + durationLedger, + endDate, + }; } export async function getVestingSchedule( @@ -32,30 +156,129 @@ export async function getVestingSchedule( throw new VestingNotFoundError(keyId, wallet); } - const total = BigInt(schedule.totalKeys.toString()); - const claimed = BigInt(schedule.claimedKeys.toString()); - const start = schedule.startLedger; - const end = schedule.endLedger; + return toVestingSchedule(schedule, currentLedger); +} - let vested = 0n; - if (currentLedger >= end) { - vested = total; - } else if (currentLedger > start) { - const elapsed = BigInt(currentLedger - start); - const duration = BigInt(end - start); - vested = (total * elapsed) / duration; +export async function getKeyVestingSummary( + keyId: string, + currentLedger: number +): Promise { + const cacheKey = getVestingCacheKey(keyId); + const cached = await cacheGetJson(cacheKey); + if (cached) { + return cached; } - const claimable = vested > claimed ? vested - claimed : 0n; + const schedules = await prisma.vestingSchedule.findMany({ + where: { keyId }, + orderBy: [{ endLedger: 'asc' }, { wallet: 'asc' }], + }); - return { - keyId: schedule.keyId, - wallet: schedule.wallet, - totalKeys: total.toString(), - startLedger: start, - endLedger: end, - claimedKeys: claimed.toString(), - vestedAmount: vested.toString(), - claimableAmount: claimable.toString(), + if (schedules.length === 0) { + throw new KeyVestingNotFoundError(keyId); + } + + const normalizedSchedules = schedules.map(schedule => + toVestingSchedule(schedule, currentLedger) + ); + const totalKeys = normalizedSchedules.reduce( + (acc, item) => acc + BigInt(item.totalKeys), + 0n + ); + const totalClaimedKeys = normalizedSchedules.reduce( + (acc, item) => acc + BigInt(item.claimedKeys), + 0n + ); + const totalClaimableAmount = normalizedSchedules.reduce( + (acc, item) => acc + BigInt(item.claimableAmount), + 0n + ); + const durationLedger = normalizedSchedules.reduce( + (acc, item) => Math.max(acc, item.durationLedger), + 0 + ); + const cliffLedger = normalizedSchedules.reduce( + (acc, item) => Math.min(acc, item.cliffLedger), + Number.MAX_SAFE_INTEGER + ); + const endDate = normalizedSchedules.reduce( + (acc, item) => (acc && item.endDate ? new Date(acc) > new Date(item.endDate) ? acc : item.endDate : item.endDate ?? acc), + null as string | null + ); + + const summary: KeyVestingSummary = { + keyId, + currentLedger, + totalKeys: totalKeys.toString(), + totalClaimableAmount: totalClaimableAmount.toString(), + totalClaimedKeys: totalClaimedKeys.toString(), + cliff: cliffLedger === Number.MAX_SAFE_INTEGER ? 0 : cliffLedger, + cliffLedger: cliffLedger === Number.MAX_SAFE_INTEGER ? 0 : cliffLedger, + duration: durationLedger, + durationLedger, + endDate, + schedules: normalizedSchedules, }; + + await cacheSetJson(cacheKey, summary, VESTING_CACHE_TTL_SECONDS); + return summary; +} + +export async function getKeyVestingHistory( + keyId: string, + limit = 20 +): Promise { + const history = await prisma.vestingClaimHistory.findMany({ + where: { keyId }, + orderBy: [{ claimedAt: 'desc' }], + take: Math.max(1, Math.min(limit, 100)), + }); + + return history.map(entry => ({ + id: entry.id, + keyId: entry.keyId, + wallet: entry.wallet, + claimedAmount: entry.claimedAmount, + txHash: entry.txHash, + ledger: entry.ledger, + claimedAt: entry.claimedAt.toISOString(), + })); +} + +export async function invalidateKeyVestingCache(keyId: string): Promise { + await cacheInvalidate(getVestingCacheKey(keyId), getVestingHistoryCacheKey(keyId)); +} + +export async function processVestingClaimEvent(event: { + eventType?: string; + keyId: string; + wallet: string; + claimedAmount: string; + txHash?: string | null; + ledger?: number | null; +}): Promise { + if (event.eventType && event.eventType !== 'VestingClaimed') { + return; + } + + const schedule = await prisma.vestingSchedule.findUnique({ + where: { keyId_wallet: { keyId: event.keyId, wallet: event.wallet } }, + }); + + if (!schedule) { + return; + } + + await prisma.vestingClaimHistory.create({ + data: { + vestingId: schedule.id, + keyId: schedule.keyId, + wallet: schedule.wallet, + claimedAmount: event.claimedAmount, + txHash: event.txHash ?? null, + ledger: event.ledger ?? null, + }, + }); + + await invalidateKeyVestingCache(event.keyId); }