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
26 changes: 26 additions & 0 deletions prisma/schema/vesting.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
66 changes: 66 additions & 0 deletions src/modules/keys/keys.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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<any>(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<any>(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.
Expand Down
118 changes: 118 additions & 0 deletions src/modules/vesting/vesting.routes.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
102 changes: 93 additions & 9 deletions src/modules/vesting/vesting.routes.ts
Original file line number Diff line number Diff line change
@@ -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<any>(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<any>(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
*
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -101,19 +178,26 @@ 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 } },
data: { claimedKeys: newClaimed.toString() },
});

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',
Expand Down
Loading
Loading